Compare commits

..
3 Commits
5 changed files with 204 additions and 31 deletions
+11 -9
View File
@@ -1,7 +1,7 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.3-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases) [![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,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. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.3.3) ## Download (v1.3.4)
### Linux ### Linux
@@ -92,21 +92,23 @@ sudo eopkg it helixnotes
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
#### .rpm (manual)
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
### Windows ### Windows
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11) [Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs) [Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update): > **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
> >
@@ -116,7 +118,7 @@ sudo eopkg it helixnotes
### Android ### Android
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk) [Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
--- ---
+3 -3
View File
@@ -1,14 +1,14 @@
use crate::search::SearchIndex; use crate::search::SearchIndex;
use crate::types::AppConfig; use crate::types::AppConfig;
use notify::RecommendedWatcher; use crate::vault::watcher::VaultWatcher;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
pub struct AppState { pub struct AppState {
pub config: Mutex<AppConfig>, pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>, pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>, pub watcher: Mutex<Option<VaultWatcher>>,
pub vault_transition: tokio::sync::Mutex<()>, pub vault_transition: tokio::sync::Mutex<()>,
pub importing: AtomicBool, pub importing: AtomicBool,
pub syncing: AtomicBool, pub syncing: AtomicBool,
+80 -7
View File
@@ -1,4 +1,4 @@
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path; use std::path::Path;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
@@ -8,14 +8,57 @@ use crate::state::AppState;
use crate::types::FileEvent; use crate::types::FileEvent;
use crate::vault::operations::helixnotes_dir; use crate::vault::operations::helixnotes_dir;
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> { const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatcherBackend {
Recommended,
Poll,
}
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
if is_ios {
WatcherBackend::Poll
} else {
WatcherBackend::Recommended
}
}
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
match backend {
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
WatcherBackend::Poll => IOS_POLL_INTERVAL,
}
}
pub enum VaultWatcher {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl VaultWatcher {
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
match self {
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
}
}
}
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new( let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
tx, let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
Config::default().with_poll_interval(Duration::from_secs(1)), let mut watcher = match backend {
) WatcherBackend::Recommended => VaultWatcher::Recommended(
.map_err(|e| e.to_string())?; RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
),
WatcherBackend::Poll => {
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
}
};
watcher watcher
.watch(Path::new(&vault_path), RecursiveMode::Recursive) .watch(Path::new(&vault_path), RecursiveMode::Recursive)
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
Ok(watcher) Ok(watcher)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_uses_polling_backend() {
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
}
#[test]
fn other_platforms_keep_recommended_backend() {
assert_eq!(
watcher_backend_for_target(false),
WatcherBackend::Recommended
);
}
#[test]
fn watcher_backends_use_the_expected_intervals() {
assert_eq!(
poll_interval_for_backend(WatcherBackend::Poll),
Duration::from_secs(10)
);
assert_eq!(
poll_interval_for_backend(WatcherBackend::Recommended),
Duration::from_secs(1)
);
}
}
+79 -12
View File
@@ -610,6 +610,14 @@
contextMenu = { x, y, notebook: nb }; contextMenu = { x, y, notebook: nb };
} }
function openNotebookMenu(e: Event, nb: NotebookEntry) {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
contextMenu = { x, y, notebook: nb };
}
function closeContextMenu() { function closeContextMenu() {
contextMenu = null; contextMenu = null;
} }
@@ -892,8 +900,11 @@
</aside> </aside>
{#if contextMenu} {#if contextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}> <div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}> <button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
New Sub-notebook New Sub-notebook
@@ -920,8 +931,11 @@
{/if} {/if}
{#if trashContextMenu} {#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}> <div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button class="danger" onclick={handleEmptyTrash}> <button class="danger" onclick={handleEmptyTrash}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" /> <polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
@@ -935,7 +949,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}> <div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}> <div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4> <h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p> <p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions"> <div class="delete-confirm-actions">
@@ -965,6 +979,7 @@
/> />
</div> </div>
{:else} {:else}
<div class="notebook-row" data-nb-path={nb.path}>
<button <button
class="notebook-item" class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path} class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
@@ -1064,6 +1079,17 @@
</span> </span>
{/if} {/if}
</button> </button>
{#if isMobile}
<button
type="button"
class="notebook-actions-btn"
aria-label={`Actions for ${nb.name}`}
onclick={(e) => openNotebookMenu(e, nb)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
</button>
{/if}
</div>
{/if} {/if}
{#if hasChildren && !isCollapsed} {#if hasChildren && !isCollapsed}
{#each nb.children as child (child.path)} {#each nb.children as child (child.path)}
@@ -1306,6 +1332,10 @@
padding: 0 2px; padding: 0 2px;
} }
.notebook-row {
position: relative;
}
.notebook-item { .notebook-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1413,6 +1443,28 @@
cursor: grabbing; cursor: grabbing;
} }
.notebook-actions-btn {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--text-tertiary);
}
.notebook-actions-btn:active {
background: var(--bg-hover);
color: var(--text-primary);
}
.tag-list { .tag-list {
padding: 0 4px; padding: 0 4px;
} }
@@ -1481,6 +1533,15 @@
min-width: 140px; min-width: 140px;
} }
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.18);
}
.context-menu button { .context-menu button {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1635,7 +1696,7 @@
} }
.sidebar.mobile .notebook-item { .sidebar.mobile .notebook-item {
padding: 12px 12px; padding: 2px 56px 2px 12px;
min-height: 48px; min-height: 48px;
gap: 10px; gap: 10px;
font-size: 15px; font-size: 15px;
@@ -1676,34 +1737,40 @@
display: none; display: none;
} }
.sidebar.mobile .context-menu { .context-menu.mobile {
min-width: 200px; left: calc(12px + env(safe-area-inset-left, 0px)) !important;
right: calc(12px + env(safe-area-inset-right, 0px));
top: auto !important;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
min-width: 0;
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
overflow-y: auto;
border-radius: 12px; border-radius: 12px;
padding: 6px; padding: 6px;
} }
.sidebar.mobile .context-menu button { .context-menu.mobile button {
padding: 12px 16px; padding: 12px 16px;
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
border-radius: 8px; border-radius: 8px;
} }
.sidebar.mobile .delete-confirm { .delete-confirm.mobile {
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
padding: 24px; padding: 24px;
} }
.sidebar.mobile .delete-confirm h4 { .delete-confirm.mobile h4 {
font-size: 16px; font-size: 16px;
} }
.sidebar.mobile .delete-confirm p { .delete-confirm.mobile p {
font-size: 14px; font-size: 14px;
} }
.sidebar.mobile .delete-confirm-cancel, .delete-confirm.mobile .delete-confirm-cancel,
.sidebar.mobile .delete-confirm-btn { .delete-confirm.mobile .delete-confirm-btn {
padding: 10px 20px; padding: 10px 20px;
font-size: 14px; font-size: 14px;
min-height: 44px; min-height: 44px;
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const sidebar = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
test('mobile notebook rows expose an accessible actions button', () => {
assert.match(sidebar, /\{#if isMobile\}[\s\S]{0,500}class="notebook-actions-btn"/);
assert.match(sidebar, /aria-label=\{`Actions for \$\{nb\.name\}`\}/);
assert.match(sidebar, /onclick=\{\(e\) => openNotebookMenu\(e, nb\)\}/);
});
test('the notebook context menu receives its mobile styling outside the sidebar', () => {
assert.match(sidebar, /class="context-menu" class:mobile=\{isMobile\}/);
assert.match(sidebar, /\.context-menu\.mobile\s*\{/);
});
test('mobile action sheets stay inside every safe area and prevent tap-through', () => {
assert.match(sidebar, /class="context-menu-backdrop"/);
assert.match(sidebar, /safe-area-inset-left/);
assert.match(sidebar, /safe-area-inset-right/);
assert.match(sidebar, /safe-area-inset-top/);
assert.match(sidebar, /safe-area-inset-bottom/);
});
test('the actions button remains inside the notebook manual-sort drop target', () => {
assert.match(sidebar, /<div class="notebook-row" data-nb-path=\{nb\.path\}>/);
});