Compare commits

...
2 Commits
4 changed files with 193 additions and 22 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
use crate::search::SearchIndex;
use crate::types::AppConfig;
use notify::RecommendedWatcher;
use crate::vault::watcher::VaultWatcher;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc;
use std::sync::Mutex;
pub struct AppState {
pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub watcher: Mutex<Option<VaultWatcher>>,
pub vault_transition: tokio::sync::Mutex<()>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
+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::sync::mpsc;
use std::time::Duration;
@@ -8,14 +8,57 @@ use crate::state::AppState;
use crate::types::FileEvent;
use crate::vault::operations::helixnotes_dir;
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatcherBackend {
Recommended,
Poll,
}
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
if is_ios {
WatcherBackend::Poll
} else {
WatcherBackend::Recommended
}
}
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
match backend {
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
WatcherBackend::Poll => IOS_POLL_INTERVAL,
}
}
pub enum VaultWatcher {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl VaultWatcher {
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
match self {
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
}
}
}
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
tx,
Config::default().with_poll_interval(Duration::from_secs(1)),
)
.map_err(|e| e.to_string())?;
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
let mut watcher = match backend {
WatcherBackend::Recommended => VaultWatcher::Recommended(
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
),
WatcherBackend::Poll => {
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
}
};
watcher
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
Ok(watcher)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_uses_polling_backend() {
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
}
#[test]
fn other_platforms_keep_recommended_backend() {
assert_eq!(
watcher_backend_for_target(false),
WatcherBackend::Recommended
);
}
#[test]
fn watcher_backends_use_the_expected_intervals() {
assert_eq!(
poll_interval_for_backend(WatcherBackend::Poll),
Duration::from_secs(10)
);
assert_eq!(
poll_interval_for_backend(WatcherBackend::Recommended),
Duration::from_secs(1)
);
}
}
+79 -12
View File
@@ -610,6 +610,14 @@
contextMenu = { x, y, notebook: nb };
}
function openNotebookMenu(e: Event, nb: NotebookEntry) {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
contextMenu = { x, y, notebook: nb };
}
function closeContextMenu() {
contextMenu = null;
}
@@ -892,8 +900,11 @@
</aside>
{#if contextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
New Sub-notebook
@@ -920,8 +931,11 @@
{/if}
{#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button class="danger" onclick={handleEmptyTrash}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
@@ -935,7 +949,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
@@ -965,6 +979,7 @@
/>
</div>
{:else}
<div class="notebook-row" data-nb-path={nb.path}>
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
@@ -1064,6 +1079,17 @@
</span>
{/if}
</button>
{#if isMobile}
<button
type="button"
class="notebook-actions-btn"
aria-label={`Actions for ${nb.name}`}
onclick={(e) => openNotebookMenu(e, nb)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
</button>
{/if}
</div>
{/if}
{#if hasChildren && !isCollapsed}
{#each nb.children as child (child.path)}
@@ -1306,6 +1332,10 @@
padding: 0 2px;
}
.notebook-row {
position: relative;
}
.notebook-item {
display: flex;
align-items: center;
@@ -1413,6 +1443,28 @@
cursor: grabbing;
}
.notebook-actions-btn {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--text-tertiary);
}
.notebook-actions-btn:active {
background: var(--bg-hover);
color: var(--text-primary);
}
.tag-list {
padding: 0 4px;
}
@@ -1481,6 +1533,15 @@
min-width: 140px;
}
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.18);
}
.context-menu button {
display: flex;
align-items: center;
@@ -1635,7 +1696,7 @@
}
.sidebar.mobile .notebook-item {
padding: 12px 12px;
padding: 2px 56px 2px 12px;
min-height: 48px;
gap: 10px;
font-size: 15px;
@@ -1676,34 +1737,40 @@
display: none;
}
.sidebar.mobile .context-menu {
min-width: 200px;
.context-menu.mobile {
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
right: calc(12px + env(safe-area-inset-right, 0px));
top: auto !important;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
min-width: 0;
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
overflow-y: auto;
border-radius: 12px;
padding: 6px;
}
.sidebar.mobile .context-menu button {
.context-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.sidebar.mobile .delete-confirm {
.delete-confirm.mobile {
max-width: calc(100vw - 40px);
padding: 24px;
}
.sidebar.mobile .delete-confirm h4 {
.delete-confirm.mobile h4 {
font-size: 16px;
}
.sidebar.mobile .delete-confirm p {
.delete-confirm.mobile p {
font-size: 14px;
}
.sidebar.mobile .delete-confirm-cancel,
.sidebar.mobile .delete-confirm-btn {
.delete-confirm.mobile .delete-confirm-cancel,
.delete-confirm.mobile .delete-confirm-btn {
padding: 10px 20px;
font-size: 14px;
min-height: 44px;
+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\}>/);
});