Compare commits

...
9 Commits
58 changed files with 2410 additions and 518 deletions
+11 -7
View File
@@ -5,7 +5,7 @@ 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.1.6)
## Download (v1.1.9)
### Linux
@@ -23,19 +23,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.6/HelixNotes_1.1.6_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_amd64.deb) — Ubuntu 22.04+
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_amd64.deb) — Ubuntu 22.04+
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_x64-setup.exe) — Windows 10/11
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64-setup.exe) — Windows 10/11
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_android.apk) — Tested on Android 16
---
@@ -50,7 +54,7 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
- **Full-text search** powered by Tantivy
- **Math support** — KaTeX rendering for inline and block equations
- **Daily notes** — one-click button to create or open today's note
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI)
- **AI writing tools** — improve, summarize, translate, and more (Ollama / Anthropic / OpenAI)
- **Version history** — per-note snapshots with diff view
- **Backups** — automatic zip-based vault backups
- **PDF preview** — inline rendering of embedded PDFs
@@ -70,7 +74,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
- **Platforms**: Linux (AppImage), Windows, macOS
- **Platforms**: Linux (AppImage), Windows, macOS, Android
## Building from Source
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.1.8",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+322 -2
View File
@@ -102,6 +102,27 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.8.2"
@@ -617,6 +638,21 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1090,6 +1126,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1123,6 +1165,26 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
dependencies = [
"fax_derive",
]
[[package]]
name = "fax_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1168,6 +1230,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1480,6 +1548,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1531,6 +1609,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -1691,6 +1779,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1750,14 +1849,17 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.1.8"
version = "1.2.0"
dependencies = [
"arboard",
"chrono",
"dirs",
"futures",
"gray_matter",
"image",
"log",
"notify",
"png 0.17.16",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -2072,9 +2174,25 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core 0.5.1",
"zune-jpeg 0.5.12",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
@@ -2653,6 +2771,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "notify"
version = "8.2.0"
@@ -3007,6 +3134,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -3096,6 +3233,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.13.0",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3449,6 +3597,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4734,7 +4888,7 @@ version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -5273,6 +5427,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tiff"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg 0.4.21",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -5585,6 +5753,17 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -5952,6 +6131,76 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.4",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
dependencies = [
"bitflags 2.11.0",
"rustix 1.1.4",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
dependencies = [
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
dependencies = [
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.89"
@@ -6070,6 +6319,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6620,6 +6875,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.4",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.2"
@@ -6701,6 +6974,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -6961,6 +7251,36 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
"zune-core 0.5.1",
]
[[package]]
name = "zvariant"
version = "5.10.0"
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.1.8"
version = "1.2.0"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -38,7 +38,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
futures = "0.3"
rayon = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 B

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+206 -8
View File
@@ -11,9 +11,21 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?;
// Initialize search index
let search = SearchIndex::new(&path)?;
search.rebuild(&path)?;
// Initialize search index — rebuild in background on Android (FUSE is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")]
{
let search_bg = search.clone();
let vault = path.clone();
std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault);
log::info!("Android: search index rebuild complete");
});
}
#[cfg(not(target_os = "android"))]
{
search.rebuild(&path)?;
}
*state.search_index.lock().map_err(|e| e.to_string())? = Some(search);
// Start file watcher
@@ -388,6 +400,128 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
Ok(entries)
}
// ── Graph ──
#[tauri::command]
pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphData, String> {
use std::collections::{HashMap, HashSet};
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing)
let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(".sync-conflict-") { continue; }
}
// Deduplicate by canonical path (handles symlinks)
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let canonical_str = canonical.to_string_lossy().to_string();
if !seen_paths.insert(canonical_str) { continue; }
let raw = std::fs::read_to_string(path).unwrap_or_default();
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
let title = extract_title_fast(&raw).unwrap_or_else(|| {
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
});
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx);
graph_nodes.push(crate::types::GraphNode {
title,
path: path_str,
});
contents.push(raw);
}
// Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new();
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 1 < len {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
i += 2;
let start = i;
while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') {
i += 1;
}
if i + 1 < len {
let link_raw = &body[start..i];
// Strip |alias, #heading, ^block
let link = link_raw.split('|').next().unwrap_or(link_raw);
let link = link.split('#').next().unwrap_or(link);
let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) {
if target_idx != source_idx {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
if edge_set.insert(key) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx });
}
}
}
i += 2;
}
} else {
i += 1;
}
}
}
Ok(crate::types::GraphData { nodes: graph_nodes, edges })
}
/// Fast title extraction from frontmatter without full YAML parsing.
/// Scans for `title: ...` line within `---` fences.
fn extract_title_fast(raw: &str) -> Option<String> {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") { return None; }
// Find the closing ---
let after_open = &trimmed[3..];
let end = after_open.find("\n---")?;
let frontmatter = &after_open[..end];
for line in frontmatter.lines() {
let line = line.trim();
if line.starts_with("title:") {
let val = line[6..].trim();
// Strip surrounding quotes
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
return Some(val[1..val.len()-1].to_string());
}
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
// ── Search ──
#[tauri::command]
@@ -462,6 +596,68 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
operations::save_vault_state(vault_path, &vault_state)
}
// ── Clipboard ──
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
let img = clipboard
.get_image()
.map_err(|_| "No image on clipboard".to_string())?;
// Encode RGBA data to PNG
let mut buf: Vec<u8> = Vec::new();
{
let mut encoder =
png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("PNG header failed: {}", e))?;
writer
.write_image_data(&img.bytes)
.map_err(|e| format!("PNG encode failed: {}", e))?;
}
Ok(buf)
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string())
}
/// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ──
#[tauri::command]
@@ -1523,11 +1719,13 @@ pub fn get_install_type() -> String {
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
"deb".to_string()
} else if std::path::Path::new("/var/lib/pacman/local").exists()
&& std::process::Command::new("pacman")
.args(["-Q", "helixnotes"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
&& ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"].iter().any(|pkg| {
std::process::Command::new("pacman")
.args(["-Q", pkg])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
{
"aur".to_string()
} else if std::env::var("APPIMAGE").is_ok() {
+9 -1
View File
@@ -44,7 +44,7 @@ pub fn run() {
)?;
}
// On Android, set config dir from Tauri's path resolver
// On Android, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")]
{
if let Ok(config_dir) = app.path().config_dir() {
@@ -52,6 +52,11 @@ pub fn run() {
} else if let Ok(data_dir) = app.path().data_dir() {
commands::set_android_config_dir(data_dir);
}
// Reload config now that the Android config dir is available
let reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded;
});
}
#[cfg(not(target_os = "android"))]
@@ -107,6 +112,7 @@ pub fn run() {
commands::move_note,
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_graph_data,
commands::search_notes,
commands::reindex,
commands::get_trash,
@@ -115,6 +121,8 @@ pub fn run() {
commands::empty_trash,
commands::load_vault_state,
commands::save_vault_state,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image,
commands::save_attachment,
commands::get_notebook_icons,
+7 -5
View File
@@ -77,18 +77,20 @@ impl SearchIndex {
for entry in WalkDir::new(vault_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
.filter_entry(|e| {
let p = e.path();
p.is_file()
&& p.extension().and_then(|x| x.to_str()) == Some("md")
&& !p.starts_with(&hn_dir)
!p.starts_with(&hn_dir)
&& !p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
})
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{
if let Ok(raw) = fs::read_to_string(entry.path()) {
let filename = entry.file_name().to_string_lossy().to_string();
+2 -1
View File
@@ -3,10 +3,11 @@ use crate::types::AppConfig;
use notify::RecommendedWatcher;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc;
pub struct AppState {
pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<SearchIndex>>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
+18
View File
@@ -254,3 +254,21 @@ pub struct NoteTitleEntry {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: usize,
pub target: usize,
}
+176 -41
View File
@@ -59,11 +59,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
return entries;
};
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_dir() && !is_hidden(&path)
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path())
})
.collect();
@@ -74,6 +74,8 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.cmp(&b.file_name().to_string_lossy().to_lowercase())
});
// Store note_count for the parent directory (used when this is the top-level call)
// Each directory pushes its own entry with its own note_count
for dir_entry in dirs {
let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string();
@@ -83,32 +85,71 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.to_string_lossy()
.to_string();
let children = scan_dir_recursive(&path, vault_root);
let note_count = count_notes_in_dir(&path);
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
entries.push(NotebookEntry {
name,
path: path.to_string_lossy().to_string(),
relative_path: relative,
children,
note_count,
note_count: child_note_count,
});
}
entries
}
fn count_notes_in_dir(dir: &Path) -> usize {
fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md")
})
.count()
})
.unwrap_or(0)
/// Combined scan: returns (children, note_count) in a single read_dir pass.
fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usize) {
let root = Path::new(vault_root);
let Ok(read_dir) = fs::read_dir(dir) else {
return (Vec::new(), 0);
};
let mut subdirs = Vec::new();
let mut note_count = 0usize;
for entry in read_dir.filter_map(|e| e.ok()) {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if ft.is_dir() && !is_hidden(&entry.path()) {
subdirs.push(entry);
} else if ft.is_file() && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
note_count += 1;
}
}
subdirs.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.to_lowercase()
.cmp(&b.file_name().to_string_lossy().to_lowercase())
});
let mut entries = Vec::new();
for dir_entry in subdirs {
let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
entries.push(NotebookEntry {
name,
path: path.to_string_lossy().to_string(),
relative_path: relative,
children,
note_count: child_note_count,
});
}
(entries, note_count)
}
fn is_hidden(path: &Path) -> bool {
@@ -129,37 +170,88 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
let root = Path::new(scan_path);
let vault_root = Path::new(vault_path);
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
if !root.exists() {
return Err("Path does not exist".to_string());
}
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
// Only direct children for a specific notebook
fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
} else {
// Recursive for "All Notes"
WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
};
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
#[cfg(target_os = "android")]
{
let mut notes = Vec::new();
// Log what read_dir sees
if let Ok(rd) = fs::read_dir(root) {
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
for (i, entry) in items.iter().enumerate().take(5) {
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
}
} else {
log::info!("scan_notes: read_dir FAILED for {:?}", root);
}
// Process files in parallel with partial reads
let mut notes: Vec<NoteEntry> = md_files
.par_iter()
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect();
if notebook_path.is_some() {
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.filter_map(|e| e.ok()) {
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
notes.push(note);
}
}
}
}
} else {
let hn_dir = helixnotes_dir(vault_path);
for entry in WalkDir::new(root)
.into_iter()
// filter_entry skips descending into hidden dirs (unlike filter)
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
notes.push(note);
}
}
}
log::info!("scan_notes: Android scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes)
#[cfg(not(target_os = "android"))]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
} else {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
};
let mut notes: Vec<NoteEntry> = md_files
.par_iter()
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect();
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes)
}
}
fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
@@ -167,6 +259,49 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
read_note_entry_from_str(&raw, path, vault_root)
}
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB — enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(bytes_read);
let raw = String::from_utf8_lossy(&buf);
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut meta, _content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) {
if let Ok(m) = fs_meta.modified() {
meta.modified = m.into();
}
if let Ok(c) = fs_meta.created() {
meta.created = c.into();
}
}
let relative = path
.strip_prefix(vault_root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
Ok(NoteEntry {
path: path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview: String::new(),
})
}
/// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview).
fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.1.8",
"version": "1.2.0",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+12
View File
@@ -126,6 +126,10 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles");
}
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
return invoke("get_graph_data");
}
export async function searchNotes(
query: string,
limit?: number,
@@ -164,6 +168,14 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
return invoke("save_vault_state", { vaultState });
}
export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image");
}
export async function copyImageToClipboard(path: string): Promise<void> {
return invoke("copy_image_to_clipboard", { path });
}
export async function saveImage(name: string, data: number[]): Promise<string> {
return invoke("save_image", { name, data });
}
+223 -108
View File
@@ -29,13 +29,18 @@
showSettings,
sourceMode,
mobileView,
appConfig
appConfig,
quickAccessPaths,
tags,
notes,
updateAvailable as globalUpdateAvailable,
settingsTab
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
@@ -46,7 +51,12 @@
let editor: Editor;
let unlistenFileChange: (() => void) | null = null;
let unlistenOpenFile: (() => void) | null = null;
// Mobile editor header helpers
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
@@ -98,6 +108,7 @@
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => {
editor?.flushSave();
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
@@ -114,6 +125,7 @@
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return;
try {
const content = await readNote(filePath);
editor?.flushSave();
$activeNote = content;
$activeNotePath = filePath;
$editorDirty = false;
@@ -125,7 +137,7 @@
const persistState = debounce(async () => {
const state: VaultState = {
last_open_note: null,
last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed,
@@ -152,12 +164,11 @@
}
function handleViewChanged() {
noteList?.refresh(true);
noteList?.refresh();
if (isMobile) $mobileView = 'notelist';
}
async function createAndFocusNote() {
if (isMobile && $mobileView !== 'notelist') $mobileView = 'notelist';
await noteList?.handleCreateNote();
editor?.focusTitle();
if (isMobile) $mobileView = 'editor';
@@ -167,6 +178,7 @@
try {
const entry = await createDailyNote();
const content = await readNote(entry.path);
editor?.flushSave();
$activeNote = content;
$activeNotePath = entry.path;
$editorDirty = false;
@@ -179,6 +191,38 @@
}
}
// Android back gesture / hardware back button support
let mobileNavFromPopstate = false;
if (isMobile) {
// Seed initial history state
history.replaceState({ mobileView: 'sidebar' }, '');
// When mobileView changes forward, push browser history so Android back gesture works
$effect(() => {
const view = $mobileView;
if (mobileNavFromPopstate) {
mobileNavFromPopstate = false;
return;
}
// Replace state to track current view
history.pushState({ mobileView: view }, '');
});
window.addEventListener('popstate', (e) => {
const currentView = $mobileView;
if (currentView === 'sidebar') {
// Already at root — let Android handle it (exit app)
return;
}
mobileNavFromPopstate = true;
if (currentView === 'editor') $mobileView = 'notelist';
else if (currentView === 'notelist') $mobileView = 'sidebar';
// Push state again so next back gesture also works
history.pushState({ mobileView: $mobileView }, '');
});
}
function mobileBack() {
if ($mobileView === 'editor') $mobileView = 'notelist';
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
@@ -270,28 +314,84 @@
persistState();
});
$effect(() => {
$activeNotePath;
persistState();
});
onMount(async () => {
let lastNotePath: string | null = null;
try {
const state = await loadVaultState();
$sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed;
$collapsedNotebooks = state.collapsed_notebooks ?? [];
lastNotePath = state.last_open_note ?? null;
} catch (_) {}
// On mobile, prefetch last-opened note so first tap is instant
let prefetchPromise: Promise<any> | null = null;
if (isMobile && lastNotePath) {
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
applyTheme($theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if ($theme === 'system') applyTheme('system');
});
await sidebar?.refresh();
await noteList?.refresh();
// Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
unlistenFileChange = await listen<FileEvent>('file-changed', async () => {
await sidebar?.refresh();
await noteList?.refresh(true);
});
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
if (isMobile) {
const tagMap = new Map<string, number>();
for (const note of $notes) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
$tags = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}
// On mobile, auto-load the last-opened note so it's ready when the user taps it
if (isMobile && prefetchPromise) {
prefetchPromise.then((noteContent) => {
if (noteContent && lastNotePath && !$activeNotePath) {
$activeNote = noteContent;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, noteContent.content);
}
});
}
if (isMobile) {
// Mobile: debounce file-changed heavily and skip when actively editing
// (our own saves trigger the watcher, causing expensive refreshes on FUSE)
const debouncedRefresh = debounce(async () => {
if (get(editorDirty)) return; // user is still typing, skip
await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
// Re-derive tags from refreshed notes
const tagMap = new Map<string, number>();
for (const note of get(notes)) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
}, 3000);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedRefresh();
});
} else {
unlistenFileChange = await listen<FileEvent>('file-changed', async () => {
await sidebar?.refresh();
await noteList?.refresh(true);
});
}
// Listen for file open events (from single-instance or OS file associations)
unlistenOpenFile = await listen<string>('open-file', async (event) => {
@@ -340,18 +440,23 @@
</svg>
</div>
{/if}
<span class="mobile-header-title">
{#if $mobileView === 'sidebar'}
HelixNotes
{:else if $mobileView === 'notelist'}
{$activeNotebook?.name || 'All Notes'}
{:else}
{$activeNote?.meta.title || 'Untitled'}
{#if $mobileView !== 'editor'}
<span class="mobile-header-title">
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{$activeNotebook?.name || 'All Notes'}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
<button class="mobile-update-badge" onclick={() => { $settingsTab = 'updates'; $showSettings = true; }}>
v{$globalUpdateAvailable.version}
</button>
{/if}
</span>
{/if}
<div class="mobile-header-actions">
{#if $mobileView === 'editor'}
<button class="mobile-header-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)}>
<button class="mobile-header-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Edit' : 'View'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if $readOnly}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
@@ -362,26 +467,84 @@
{/if}
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.openNoteSearch()} title="Find in note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
<button class="mobile-header-btn" class:active={$activeNote?.meta.pinned} onclick={() => { if ($activeNote) { $activeNote.meta.pinned = !$activeNote.meta.pinned; $editorDirty = true; } }} title="Pin">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg>
</button>
<button class="mobile-header-btn" class:active={isQuickAccess} onclick={async () => {
if (!noteRelativePath) return;
try {
if (isQuickAccess) { await removeQuickAccess(noteRelativePath); } else { await addQuickAccess(noteRelativePath); }
$quickAccessPaths = (await getQuickAccess()).map(n => n.relative_path);
} catch (e) { console.error('Quick access toggle failed:', e); }
}} title="Quick Access">
<svg width="18" height="18" viewBox="0 0 24 24" fill={isQuickAccess ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.toggleOutlinePanel()} title="Outline">
<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="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.toggleHistoryPanel()} title="History">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</button>
{#if $appConfig?.enable_wiki_links}
<button class="mobile-header-btn" onclick={() => editor?.toggleGraphView()} title="Graph View">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/>
<line x1="8.5" y1="7.5" x2="15.5" y2="16.5"/><line x1="15.5" y1="7.5" x2="8.5" y2="16.5"/>
</svg>
</button>
{/if}
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
<button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions">
<svg width="18" height="18" 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"/>
</svg>
</button>
{/if}
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</button>
{:else}
<button class="mobile-header-btn" onclick={createAndFocusNote}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<button class="mobile-header-btn" onclick={() => ($showSearch = true)} title="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
<button class="mobile-header-btn" onclick={createAndFocusNote} title="New Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
{/if}
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
</div>
</div>
@@ -391,47 +554,13 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
</div>
</div>
<!-- Mobile Bottom Nav -->
{#if $mobileView !== 'editor'}
<div class="mobile-bottom-nav">
<button class="mobile-nav-item" class:active={$mobileView === 'sidebar'} onclick={() => ($mobileView = 'sidebar')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
<span>Notebooks</span>
</button>
<button class="mobile-nav-item" onclick={() => $showSearch = true}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span>Search</span>
</button>
<button class="mobile-nav-item" onclick={handleDailyNote}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily</span>
</button>
<button class="mobile-nav-item" onclick={() => ($showSettings = true)}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
<span>Settings</span>
</button>
</div>
{/if}
</div>
{:else}
<!-- ═══ DESKTOP LAYOUT ═══ -->
@@ -485,7 +614,7 @@
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
@@ -634,25 +763,45 @@
padding: 0 4px;
}
.mobile-update-badge {
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.mobile-header-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.mobile-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
width: 34px;
height: 34px;
border: none;
background: none;
color: var(--text-secondary);
border-radius: 10px;
border-radius: 8px;
cursor: pointer;
}
.mobile-header-btn svg {
width: 16px;
height: 16px;
}
.mobile-header-btn:active {
background: var(--bg-hover);
}
@@ -682,38 +831,4 @@
pointer-events: auto;
}
.mobile-bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
padding-bottom: env(safe-area-inset-bottom);
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 16px;
border: none;
background: none;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 500;
cursor: pointer;
border-radius: 8px;
transition: color 0.15s;
}
.mobile-nav-item:active {
background: var(--bg-hover);
}
.mobile-nav-item.active {
color: var(--accent);
}
</style>
File diff suppressed because it is too large Load Diff
+177 -98
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getAllNoteTitles, readNote } from '$lib/api';
import { appConfig, activeNotePath } from '$lib/stores/app';
import type { NoteTitleEntry } from '$lib/types';
import { getGraphData } from '$lib/api';
import { activeNotePath } from '$lib/stores/app';
let { onclose, onnavigate }: {
onclose: () => void;
@@ -12,6 +11,9 @@
let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true);
// Start fetching data immediately — don't wait for canvas mount
const dataPromise = getGraphData();
interface GraphNode {
id: string;
title: string;
@@ -31,7 +33,6 @@
let edges: GraphEdge[] = [];
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let animFrame = 0;
let pan = { x: 0, y: 0 };
let zoom = 1;
let dragging: GraphNode | null = null;
@@ -41,75 +42,54 @@
let panStart = { x: 0, y: 0 };
let hoveredNode: GraphNode | null = null;
let glowPhase = 0;
let glowFrame = 0;
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g;
// Cache computed styles — read once, not every frame
let cachedStyles: { border: string; text: string; textSec: string; accent: string } | null = null;
function getStyles() {
if (cachedStyles) return cachedStyles;
const style = getComputedStyle(document.documentElement);
cachedStyles = {
border: style.getPropertyValue('--border-color').trim() || '#444',
text: style.getPropertyValue('--text-primary').trim() || '#eee',
textSec: style.getPropertyValue('--text-tertiary').trim() || '#888',
accent: style.getPropertyValue('--accent').trim() || '#7b9bd4',
};
return cachedStyles;
}
async function buildGraph() {
loading = true;
try {
const titles = await getAllNoteTitles();
const titleMap = new Map<string, NoteTitleEntry>();
for (const t of titles) {
titleMap.set(t.title.toLowerCase(), t);
}
// Use the pre-fetched promise (started before canvas mount)
const data = await dataPromise;
// Create nodes
const w = canvas?.width ?? 800;
const h = canvas?.height ?? 600;
nodes = titles.map((t) => ({
id: t.title.toLowerCase(),
title: t.title,
path: t.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
}));
// Build index map for O(1) lookups
// Build node index map
nodeIndexMap = new Map();
for (let i = 0; i < nodes.length; i++) {
nodeIndexMap.set(nodes[i].id, i);
}
nodes = data.nodes.map((n, i) => {
nodeIndexMap.set(n.title.toLowerCase(), i);
return {
id: n.title.toLowerCase(),
title: n.title,
path: n.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
};
});
// Read all notes in parallel (batched) to extract [[wiki-links]]
const edgeSet = new Set<string>();
edges = [];
const BATCH_SIZE = 20;
for (let b = 0; b < nodes.length; b += BATCH_SIZE) {
const batch = nodes.slice(b, b + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (node) => {
const content = await readNote(node.path);
return { node, body: content.content || '' };
})
);
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { node, body } = result.value;
const nodeIdx = nodeIndexMap.get(node.id)!;
let match;
wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) {
// Handle Obsidian syntax: strip |alias, #heading, ^block
let rawLink = match[1].trim();
const pipeIdx = rawLink.indexOf('|');
if (pipeIdx >= 0) rawLink = rawLink.slice(0, pipeIdx).trim();
rawLink = rawLink.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
const linkTitle = rawLink.toLowerCase();
const targetIdx = nodeIndexMap.get(linkTitle);
if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey);
edges.push({ sourceIdx: nodeIdx, targetIdx });
connectedSet.add(nodeIdx);
connectedSet.add(targetIdx);
}
}
}
}
}
// Map edges and track connected nodes
connectedSet = new Set();
edges = data.edges.map(e => {
connectedSet.add(e.source);
connectedSet.add(e.target);
return { sourceIdx: e.source, targetIdx: e.target };
});
} catch (e) {
console.error('Failed to build graph:', e);
}
@@ -121,17 +101,17 @@
if (!canvas || nodes.length === 0) return;
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
// Only center on active note if it has connections
if (!activeNode) return;
const activeIdx = nodeIndexMap.get(activeNode.id);
if (activeIdx === undefined || !connectedSet.has(activeIdx)) return;
// Gather the active node and its direct neighbors
const neighborhood: GraphNode[] = [activeNode];
for (const edge of edges) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
if (activeIdx !== undefined) {
for (const edge of edges) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
}
}
const w = canvas.width;
@@ -193,18 +173,13 @@
}
function startSimulation() {
if (animFrame) cancelAnimationFrame(animFrame);
// Run a small batch synchronously for an instant first render
for (let i = 0; i < 30; i++) simulate();
// Run physics synchronously — no need to animate the settling
for (let i = 0; i < 300; i++) {
simulate();
}
// Center on active note if it has links, otherwise fit all
// Center/fit immediately so the user sees something right away
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
const activeIdx = activeNode ? nodeIndexMap.get(activeNode.id) : undefined;
if (activeIdx !== undefined && connectedSet.has(activeIdx)) {
if (activeNode) {
centerOnActiveNote();
} else {
fitToView();
@@ -212,9 +187,21 @@
draw();
startGlowLoop();
}
let glowFrame = 0;
// Continue settling asynchronously in small batches
const totalRemaining = Math.min(270, Math.max(70, nodes.length * 2));
let done = 0;
function settle() {
if (done >= totalRemaining) return;
const batch = Math.min(20, totalRemaining - done);
for (let i = 0; i < batch; i++) simulate();
done += batch;
// Re-center while settling
if (activeNode) centerOnActiveNote(); else fitToView();
requestAnimationFrame(settle);
}
requestAnimationFrame(settle);
}
function startGlowLoop() {
if (glowFrame) cancelAnimationFrame(glowFrame);
@@ -235,16 +222,19 @@
const centerX = w / 2;
const centerY = h / 2;
// Repulsion between all nodes
// Repulsion between all nodes (skip pairs that are very far apart)
for (let i = 0; i < nodeCount; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodeCount; j++) {
const b = nodes[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const distSq = dx * dx + dy * dy || 1;
const force = 800 / distSq;
const dist = Math.sqrt(distSq);
const distSq = dx * dx + dy * dy;
// Skip very distant pairs — negligible force
if (distSq > 250000) continue;
const d = distSq || 1;
const force = 800 / d;
const dist = Math.sqrt(d);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx -= fx;
@@ -254,7 +244,7 @@
}
}
// Attraction along edges (indexed lookups)
// Attraction along edges
for (const edge of edges) {
const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx];
@@ -298,24 +288,20 @@
ctx.translate(pan.x, pan.y);
ctx.scale(zoom, zoom);
const style = getComputedStyle(document.documentElement);
const borderColor = style.getPropertyValue('--border-color').trim() || '#444';
const textColor = style.getPropertyValue('--text-primary').trim() || '#eee';
const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888';
const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4';
const { border: borderColor, text: textColor, textSec: textSecondary, accent } = getStyles();
// Draw edges
ctx.strokeStyle = borderColor;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.4;
ctx.beginPath();
for (const edge of edges) {
const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
ctx.stroke();
ctx.globalAlpha = 1;
// Determine active note
@@ -369,7 +355,7 @@
ctx.globalAlpha = 1;
}
// Label
// Label — only for connected/active/hovered nodes
if (isActive || isHovered || hasLinks) {
ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = isActive || isHovered ? textColor : textSecondary;
@@ -465,6 +451,84 @@
if (e.key === 'Escape') onclose();
}
// Touch support for mobile
let lastTouchDist = 0;
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
const t = e.touches[0];
mouseDownPos = { x: t.clientX, y: t.clientY };
dragMoved = false;
const node = getNodeAt(t.clientX, t.clientY);
if (node) {
dragging = node;
} else {
panning = true;
panStart = { x: t.clientX - pan.x, y: t.clientY - pan.y };
}
} else if (e.touches.length === 2) {
dragging = null;
panning = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
}
}
function handleTouchMove(e: TouchEvent) {
e.preventDefault();
if (e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - mouseDownPos.x;
const dy = t.clientY - mouseDownPos.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
if (dragging && dragMoved) {
const rect = canvas.getBoundingClientRect();
dragging.x = (t.clientX - rect.left - pan.x) / zoom;
dragging.y = (t.clientY - rect.top - pan.y) / zoom;
dragging.vx = 0;
dragging.vy = 0;
draw();
} else if (panning) {
pan.x = t.clientX - panStart.x;
pan.y = t.clientY - panStart.y;
draw();
}
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (lastTouchDist > 0) {
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const rect = canvas.getBoundingClientRect();
const mx = midX - rect.left;
const my = midY - rect.top;
const oldZoom = zoom;
zoom = Math.max(0.2, Math.min(5, zoom * (dist / lastTouchDist)));
pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
pan.y = my - (my - pan.y) * (zoom / oldZoom);
draw();
}
lastTouchDist = dist;
}
}
function handleTouchEnd(e: TouchEvent) {
if (e.touches.length === 0) {
if (dragging && !dragMoved) {
const node = dragging;
dragging = null;
onnavigate(node.path, node.title);
return;
}
dragging = null;
panning = false;
lastTouchDist = 0;
}
}
$effect(() => {
if (canvas) {
const rect = canvas.parentElement?.getBoundingClientRect();
@@ -477,7 +541,6 @@
});
onDestroy(() => {
if (animFrame) cancelAnimationFrame(animFrame);
if (glowFrame) cancelAnimationFrame(glowFrame);
});
</script>
@@ -516,6 +579,9 @@
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
></canvas>
</div>
</div>
@@ -555,6 +621,20 @@
flex-shrink: 0;
}
@media (max-width: 768px) {
.graph-panel {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
border: none;
}
.graph-header {
padding-top: calc(env(safe-area-inset-top, 36px) + 14px);
}
}
.graph-header h3 {
font-size: 15px;
font-weight: 600;
@@ -594,6 +674,7 @@
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
}
.graph-canvas:active {
@@ -604,21 +685,19 @@
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
gap: 10px;
font-size: 13px;
color: var(--text-tertiary);
z-index: 1;
}
.spinner {
animation: spin 0.8s linear infinite;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+19 -1
View File
@@ -6,9 +6,10 @@
import type { VaultStats } from '$lib/types';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>('shortcuts');
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -54,10 +55,12 @@
</div>
<div class="info-body">
{#if !isMobile}
<div class="info-tabs">
<button class="info-tab" class:active={activeTab === 'shortcuts'} onclick={() => activeTab = 'shortcuts'}>Shortcuts</button>
<button class="info-tab" class:active={activeTab === 'about'} onclick={() => activeTab = 'about'}>About</button>
</div>
{/if}
{#if activeTab === 'about'}
<div class="info-logo">
@@ -398,4 +401,19 @@
color: var(--text-tertiary);
line-height: 1.4;
}
/* Mobile */
@media (max-width: 600px) {
.info-panel {
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: none;
}
.info-header {
padding-top: calc(env(safe-area-inset-top, 12px) + 12px);
}
}
</style>
+51 -11
View File
@@ -12,7 +12,8 @@
quickAccessPaths,
appConfig,
notebooks,
tags
tags,
mobileView
} from '$lib/stores/app';
import {
getNotes,
@@ -35,14 +36,34 @@
import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: {
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: {
onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
/** Derive tags from current $notes store (avoids re-scanning files on mobile) */
function deriveTagsFromNotes() {
const tagMap = new Map<string, number>();
for (const note of $notes) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
$tags = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}
async function refreshTags() {
if (isMobile) {
deriveTagsFromNotes();
} else {
await refreshTags();
}
}
let compact = $derived($appConfig?.compact_notes ?? false);
let contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null);
let sortMenu = $state<{ x: number; y: number } | null>(null);
@@ -168,7 +189,12 @@
} else if ($viewMode === 'quickaccess') {
$notes = await getQuickAccess();
} else if ($viewMode === 'tag' && $activeTag) {
const all = await getNotes(null);
// Reuse cached "all" notes if available, otherwise fetch and cache them
let all = noteCache.get('all');
if (!all) {
all = await getNotes(null);
noteCache.set('all', all);
}
$notes = all.filter((n) => n.meta.tags.includes($activeTag!));
} else {
const nbPath = $viewMode === 'notebook' ? $activeNotebook?.path ?? null : null;
@@ -181,9 +207,13 @@
}
async function selectNote(note: NoteEntry) {
if ($activeNotePath === note.path) return;
if ($activeNotePath === note.path) {
if (isMobile) $mobileView = 'editor';
return;
}
try {
const content = await readNote(note.path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = note.path;
$editorDirty = false;
@@ -322,7 +352,7 @@
if ($activeNotePath === note.path && $activeNote) {
$activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: newTags } };
}
$tags = await getAllTags();
await refreshTags();
} catch (e) {
console.error('Failed to save tags:', e);
}
@@ -358,7 +388,7 @@
}
}
}
$tags = await getAllTags();
await refreshTags();
} catch (e) {
console.error('Failed to add tag to batch:', e);
}
@@ -380,7 +410,7 @@
}
}
}
$tags = await getAllTags();
await refreshTags();
} catch (e) {
console.error('Failed to remove tag from batch:', e);
}
@@ -447,9 +477,18 @@
}
}
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;
if (x < 4) x = 4;
if (y < 4) y = 4;
return { x, y };
}
function onContextMenu(e: MouseEvent, note: NoteEntry) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, note };
const { x, y } = clampMenu(e.clientX, e.clientY);
contextMenu = { x, y, note };
}
function startRename(note: NoteEntry) {
@@ -689,16 +728,17 @@
onclick={(e) => handleNoteClick(e, note)}
oncontextmenu={(e) => {
e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY);
// If multi-selected and right-clicking a selected note, show batch menu
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
contextMenu = { x: e.clientX, y: e.clientY, note };
contextMenu = { x: pos.x, y: pos.y, note };
return;
}
// Otherwise clear selection and show single menu
if (selectedPaths.size > 0 && !selectedPaths.has(note.path)) {
clearSelection();
}
contextMenu = { x: e.clientX, y: e.clientY, note };
contextMenu = { x: pos.x, y: pos.y, note };
}}
draggable="true"
ondragstart={(e) => {
@@ -1537,7 +1577,7 @@
}
.note-list.mobile .list-content {
padding: 4px 8px;
padding: 4px 8px 180px;
}
.note-list.mobile .note-item {
+5 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig } from '$lib/stores/app';
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
import { searchNotes, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import type { SearchResult } from '$lib/types';
@@ -83,6 +83,8 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const isMobile = /android|ios/i.test(navigator.userAgent);
async function openResult(result: SearchResult) {
try {
const content = await readNote(result.path);
@@ -90,6 +92,7 @@
$activeNotePath = result.path;
$editorDirty = false;
$showSearch = false;
if (isMobile) $mobileView = 'editor';
} catch (e) {
console.error('Failed to open search result:', e);
}
@@ -118,7 +121,7 @@
oninput={handleInput}
onkeydown={handleKeydown}
/>
<kbd class="search-esc">Esc</kbd>
{#if !isMobile}<kbd class="search-esc">Esc</kbd>{/if}
</div>
{#if results.length > 0}
+169 -12
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab } from '$lib/stores/app';
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent);
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates';
let activeTab = $state<Tab>('styling');
@@ -54,15 +56,27 @@
updateMessage = null;
updateAvailable = null;
try {
const update = await checkUpdate();
if (update) {
updateObj = update;
$globalUpdateObj = update;
updateAvailable = { version: update.version, body: update.body, date: update.date };
globalUpdateAvailable.set({ version: update.version, body: update.body });
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
if (isMobile) {
await checkForUpdateMobile();
const global = $globalUpdateAvailable;
if (global) {
updateAvailable = { version: global.version, body: global.body };
updateMessage = { type: 'info', text: `Version ${global.version} is available!` };
} else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
}
} else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
const { check: checkUpdate } = await import('@tauri-apps/plugin-updater');
const update = await checkUpdate();
if (update) {
updateObj = update;
$globalUpdateObj = update;
updateAvailable = { version: update.version, body: update.body, date: update.date };
globalUpdateAvailable.set({ version: update.version, body: update.body });
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
} else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
}
}
} catch (e) {
updateMessage = { type: 'error', text: `Failed to check: ${e}` };
@@ -506,9 +520,9 @@
{#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-panel" onclick={(e) => e.stopPropagation()}>
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-header">
<h2>Settings</h2>
<button class="close-btn" onclick={close}>
@@ -539,18 +553,22 @@
</svg>
Styling
</button>
{#if !isMobile}
<button class="tab-btn" class:active={activeTab === 'import'} onclick={() => activeTab = 'import'}>
<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 3v12"/><path d="m8 11 4 4 4-4"/><path d="M8 5H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V7a2 2 0 00-2-2h-4"/>
</svg>
Import
</button>
{/if}
{#if !isMobile}
<button class="tab-btn" class:active={activeTab === 'backup'} onclick={() => { activeTab = 'backup'; loadBackups(); }}>
<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="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 009 9 9.75 9.75 0 006.74-2.74L21 16"/><path d="M16 16h5v5"/>
</svg>
Backup
</button>
{/if}
<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"/>
@@ -590,6 +608,20 @@
</div>
</div>
{#if isMobile}
<div class="settings-section">
<h3>Vault</h3>
<p class="setting-desc" style="margin-bottom: 12px; color: var(--text-tertiary); font-size: 13px;">Current: <strong style="color: var(--text-primary);">{$appConfig?.active_vault?.split('/').pop() ?? 'Unknown'}</strong></p>
<button class="import-btn" onclick={() => { $showSettings = false; $vaultReady = false; }}>
<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 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
Switch Vault
</button>
</div>
{/if}
{#if !isMobile}
<div class="settings-section">
<h3>Performance</h3>
<label class="setting-toggle">
@@ -635,6 +667,7 @@
</label>
{/if}
</div>
{/if}
</div>
{:else if activeTab === 'editor'}
<div class="tab-content">
@@ -649,6 +682,7 @@
<span class="toggle-knob"></span>
</button>
</label>
{#if !isMobile}
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Show line numbers</span>
@@ -658,6 +692,7 @@
<span class="toggle-knob"></span>
</button>
</label>
{/if}
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Open notes in View Mode</span>
@@ -682,6 +717,7 @@
</label>
</div>
{#if !isMobile}
<div class="settings-section">
<h3>PDF Preview</h3>
<label class="setting-toggle">
@@ -713,6 +749,7 @@
<span class="setting-hint">Default height for PDF previews in notes</span>
</div>
{/if}
{/if}
</div>
{:else if activeTab === 'styling'}
<div class="tab-content">
@@ -849,6 +886,7 @@
</label>
<div class="backup-actions">
{#if !isMobile}
<button class="backup-link-btn" onclick={handleSelectBackupFolder}>
<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"/>
@@ -858,6 +896,7 @@
{#if $appConfig?.backup_location}
<p class="backup-path">{$appConfig.backup_location}</p>
{/if}
{/if}
<button class="backup-link-btn" onclick={handleBackupNow} disabled={backupLoading}>
{#if backupLoading}
<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>
@@ -1105,7 +1144,11 @@
<div class="settings-section">
<h3>Usage</h3>
{#if isMobile}
<p class="import-desc">Use the AI button in the toolbar or header to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
{:else}
<p class="import-desc">Select text in the editor and right-click to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
{/if}
</div>
{/if}
</div>
@@ -1171,6 +1214,13 @@
<p>Update via your AUR helper:</p>
<code>yay -Syu helixnotes</code>
</div>
{:else if $installType === 'android'}
<button class="update-install-btn" onclick={() => { openUrl('https://helixnotes.com/#download').catch(() => {}); }}>
<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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download from Website
</button>
{:else}
<a class="update-install-btn" href="https://codeberg.org/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">
@@ -2085,4 +2135,111 @@
color: var(--accent);
}
/* ═══ Mobile ═══ */
.settings-overlay.mobile {
align-items: stretch;
justify-content: stretch;
}
.settings-panel.mobile {
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: none;
}
.settings-panel.mobile .settings-content {
flex-direction: column;
}
.settings-panel.mobile .settings-tabs {
flex-direction: row;
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--border-light);
padding: 8px 8px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
gap: 0;
}
.settings-panel.mobile .settings-tabs::-webkit-scrollbar {
display: none;
}
.settings-panel.mobile .tab-btn {
flex-shrink: 0;
flex-direction: column;
gap: 4px;
padding: 8px 14px 10px;
font-size: 11px;
border-radius: 8px 8px 0 0;
}
.settings-panel.mobile .tab-btn.active {
border-bottom: 2px solid var(--accent);
border-radius: 8px 8px 0 0;
}
.settings-panel.mobile .settings-header {
padding-top: calc(env(safe-area-inset-top, 12px) + 12px);
}
.settings-panel.mobile .settings-body {
padding: 16px;
}
.settings-panel.mobile .setting-name {
font-size: 14px;
}
.settings-panel.mobile .setting-desc {
font-size: 12px;
}
.settings-panel.mobile .toggle-switch {
width: 44px;
height: 26px;
}
.settings-panel.mobile .toggle-knob {
width: 22px;
height: 22px;
}
.settings-panel.mobile .toggle-switch.on .toggle-knob {
transform: translateX(18px);
}
.settings-panel.mobile .option-btn {
padding: 12px 10px;
font-size: 14px;
}
.settings-panel.mobile .theme-btn {
padding: 12px 10px;
font-size: 14px;
}
.settings-panel.mobile .accent-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.settings-panel.mobile .accent-swatch {
padding: 14px 8px;
}
.settings-panel.mobile .font-size-options {
gap: 8px;
}
.settings-panel.mobile .font-size-btn {
padding: 12px 6px;
}
</style>
+65 -8
View File
@@ -51,11 +51,23 @@
export async function refresh() {
try {
$notebooks = await getNotebooks();
$tags = await getAllTags();
$notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path);
if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes] = await Promise.all([
getNotebooks(),
getNotebookIcons(),
getQuickAccess(),
]);
$notebooks = nbs;
$notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path);
} else {
$notebooks = await getNotebooks();
$tags = await getAllTags();
$notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path);
}
} catch (e) {
console.error('Failed to refresh sidebar:', e);
}
@@ -271,9 +283,18 @@
return convertFileSrc(`${vaultRoot}/${iconPath}`);
}
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 300): { x: number; y: number } {
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
return { x, y };
}
function onContextMenu(e: MouseEvent, nb: NotebookEntry) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, notebook: nb };
const { x, y } = clampMenu(e.clientX, e.clientY);
contextMenu = { x, y, notebook: nb };
}
function closeContextMenu() {
@@ -283,7 +304,8 @@
function onTrashContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
trashContextMenu = { x: e.clientX, y: e.clientY };
const { x, y } = clampMenu(e.clientX, e.clientY, 180, 60);
trashContextMenu = { x, y };
}
async function handleEmptyTrash() {
@@ -310,6 +332,7 @@
<svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
{#if !isMobile}
<div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -329,6 +352,7 @@
</button>
{/if}
</div>
{/if}
{#if !$sidebarCollapsed}
<nav class="sidebar-nav">
@@ -425,9 +449,32 @@
{@render notebookItem(nb, 0)}
{/each}
</div>
{#if isMobile && $tags.length > 0}
<div class="tags-section-inline">
<button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}>
<span class="section-title">Tags</span>
<span class="tag-count" style="margin-left: auto">{$tags.length}</span>
</button>
{#if !tagsCollapsed}
<div class="tag-list">
{#each $tags as [tag, count]}
<button
class="tag-item"
class:active={$viewMode === 'tag' && $activeTag === tag}
onclick={() => selectTag(tag)}
>
<span class="tag-hash">#</span>
<span class="tag-name">{tag}</span>
<span class="tag-count">{count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if $tags.length > 0}
{#if !isMobile && $tags.length > 0}
<div class="tags-section">
<button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}>
<span class="section-title">Tags</span>
@@ -1052,6 +1099,16 @@
height: 20px;
}
.sidebar.mobile .section {
padding-bottom: 180px;
}
.sidebar.mobile .tags-section-inline {
border-top: 1px solid var(--border-light);
padding: 4px 0;
margin-top: 8px;
}
.sidebar.mobile .section-header {
padding: 12px 16px 6px;
min-height: 44px;
+238 -7
View File
@@ -11,12 +11,56 @@
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
let loading = $state(false);
let error = $state('');
let vaultName = $state('HelixNotes');
let hasPermission = $state(!isMobile);
let selectedLocation = $state('Documents');
let customPath = $state('');
const storageLocations = [
{ label: 'Documents', path: '/storage/emulated/0/Documents' },
{ label: 'Downloads', path: '/storage/emulated/0/Download' },
{ label: 'Internal Storage', path: '/storage/emulated/0' },
];
if (isMobile) {
checkPermission();
setTimeout(checkPermission, 500);
(window as any).__storagePermissionGranted = () => { hasPermission = true; };
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300);
});
}
function checkPermission() {
try {
const bridge = (window as any).Android;
if (bridge?.hasStoragePermission) {
hasPermission = bridge.hasStoragePermission() === true;
}
} catch { /* bridge not ready */ }
}
function requestPermission() {
try {
const bridge = (window as any).Android;
bridge?.requestStoragePermission?.();
} catch {
error = 'Go to Settings > Apps > HelixNotes > Permissions and enable "All files access".';
}
}
function getMobileBasePath(): string {
if (selectedLocation === 'Custom') {
return customPath.trim() || '/storage/emulated/0/Documents';
}
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents';
}
let fullPath = $derived(isMobile ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}` : '');
async function pickFolder() {
if (isMobile) {
// Use shared Documents folder for Syncthing/Nextcloud compatibility
const vaultPath = '/storage/emulated/0/Documents/HelixNotes';
await openSelectedVault(vaultPath);
await openSelectedVault(fullPath);
} else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
if (selected) {
@@ -29,6 +73,12 @@
loading = true;
error = '';
try {
if (isMobile) {
try {
const bridge = (window as any).Android;
bridge?.prepareVaultDir?.(path);
} catch { /* Rust will retry mkdir itself */ }
}
await openVault(path);
const config = await getAppConfig();
$appConfig = config;
@@ -68,7 +118,41 @@
<h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p>
{#if isMobile}
<p class="description">Notes are stored in Documents/HelixNotes as Markdown files. Sync with Nextcloud, Syncthing, or any file sync app.</p>
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{#if !hasPermission}
<button class="btn-permission" onclick={requestPermission}>
Grant File Access
</button>
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
{/if}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-options">
{#each storageLocations as loc}
<button
class="location-option"
class:active={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label}
>{loc.label}</button>
{/each}
<button
class="location-option"
class:active={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'}
>Custom</button>
</div>
{#if selectedLocation === 'Custom'}
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
{/if}
</div>
<div class="vault-name-input">
<label for="vault-name">Vault name</label>
<div class="vault-name-preview"><input id="vault-name" type="text" bind:value={vaultName} placeholder="HelixNotes" /></div>
<span class="vault-path-hint">{fullPath}</span>
</div>
{:else}
<p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder — existing .md files will be recognized automatically.</p>
{/if}
@@ -87,13 +171,13 @@
{/if}
</button>
{#if !isMobile && $appConfig?.active_vault}
{#if $appConfig?.active_vault}
<button class="btn-back" onclick={() => ($vaultReady = true)}>
Back
</button>
{/if}
{#if !isMobile && recentVaults.length > 0}
{#if recentVaults.length > 0}
<div class="recent">
<span class="recent-label">Recent</span>
{#each recentVaults as vault}
@@ -110,11 +194,13 @@
<style>
.vault-picker {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
height: 100dvh;
background: var(--bg-primary);
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.window-controls {
@@ -148,6 +234,9 @@
max-width: 400px;
width: 100%;
padding: 48px 32px;
padding-bottom: calc(120px + env(safe-area-inset-bottom, 0px));
margin: auto;
flex-shrink: 0;
}
.logo {
@@ -276,6 +365,130 @@
white-space: nowrap;
}
.vault-name-input {
margin-bottom: 20px;
text-align: left;
}
.vault-name-input label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.vault-name-preview {
display: flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: var(--text-tertiary);
}
.vault-name-preview input {
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
width: 100%;
padding: 0;
}
.location-selector {
margin-bottom: 16px;
text-align: left;
}
.location-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.location-options {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.location-option {
padding: 8px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.location-option.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.custom-path-input {
width: 100%;
margin-top: 8px;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.custom-path-input:focus {
border-color: var(--accent);
}
.vault-path-hint {
display: block;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 6px;
opacity: 0.7;
word-break: break-all;
}
.btn-permission {
width: 100%;
padding: 12px 20px;
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-bottom: 6px;
transition: background 0.15s;
}
.btn-permission:hover {
background: var(--accent-hover);
}
.permission-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 20px;
opacity: 0.7;
}
/* Mobile */
.mobile .btn-primary {
padding: 14px 20px;
@@ -295,4 +508,22 @@
width: 88px;
height: 88px;
}
.mobile .btn-back {
padding: 14px 20px;
font-size: 16px;
border-radius: 12px;
}
.mobile .vault-item {
padding: 14px 16px;
}
.mobile .vault-name {
font-size: 15px;
}
.mobile .vault-path {
font-size: 13px;
}
</style>
+32
View File
@@ -59,6 +59,7 @@ export const updateAvailable = writable<{
} | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native");
export const androidApkUrl = writable<string | null>(null);
export async function checkForUpdate() {
try {
@@ -73,6 +74,37 @@ export async function checkForUpdate() {
}
}
function isNewerVersion(remote: string, local: string): boolean {
const r = remote.split(".").map(Number);
const l = local.split(".").map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
const rv = r[i] || 0;
const lv = l[i] || 0;
if (rv > lv) return true;
if (rv < lv) return false;
}
return false;
}
export async function checkForUpdateMobile() {
try {
const { getVersion } = await import("@tauri-apps/api/app");
const currentVersion = await getVersion();
const res = await fetch("https://helixnotes.com/latest.json");
if (!res.ok) return;
const data = await res.json();
if (data.version && isNewerVersion(data.version, currentVersion)) {
updateAvailable.set({ version: data.version, body: data.notes });
const android = data.platforms?.["android-universal"];
if (android?.url) {
androidApkUrl.set(android.url);
}
}
} catch {
// Silent fail
}
}
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
+6 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNotePath, installType, checkForUpdate } from '$lib/stores/app';
import { theme, appConfig, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, getInstallType } from '$lib/api';
import { get } from 'svelte/store';
@@ -33,9 +33,12 @@
return resolved.join('/');
}
// Detect install type and check for updates on startup (skip on mobile — updates via app store)
// Detect install type and check for updates on startup
onMount(() => {
if (!isMobile) {
if (isMobile) {
installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
}