features
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# tests/no_write_on_open.rs runs against one real git repository on disk and several of its tests
|
||||
# mutate it, so they have to run one at a time. The file has always said so in a comment; this is
|
||||
# what makes plain `cargo test` obey it, rather than leaving the suite green only for whoever
|
||||
# remembers to pass `-- --test-threads=1`.
|
||||
#
|
||||
# The other suites use a TempDir each and do not care. Serializing them costs well under a second.
|
||||
[env]
|
||||
RUST_TEST_THREADS = "1"
|
||||
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "margin-docs"
|
||||
version = "0.0.1"
|
||||
description = "A folder of markdown documents"
|
||||
authors = ["Margin"]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "margin_docs_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Gitignore-aware directory walking for the folder tree: a document folder under version control
|
||||
# should not surface .git or its own ignored build output as if they were documents.
|
||||
ignore = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.7"
|
||||
# Deleting a document goes to the OS trash rather than unlinking it outright.
|
||||
trash = "5"
|
||||
# rusqlite 0.40 has no separate "fts5" cargo feature to ask for: libsqlite3-sys's bundled build
|
||||
# compiles SQLITE_ENABLE_FTS5 in unconditionally, so "bundled" alone is what gets full text search.
|
||||
# margin-calendar does not carry this comment because it has no need for full text search.
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
|
||||
# Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app
|
||||
# corrects into, so a word learned in Mail is not underlined here, and it carries the user's own
|
||||
# languages without this app shipping a dictionary. These objc2 crates are already in the graph
|
||||
# through Tauri, so asking for them adds nothing to the build but the features named.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
|
||||
|
||||
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
|
||||
# Gated here as well as behind cfg(desktop) in lib.rs so a mobile build does not compile them at all.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window, on every platform",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "opener:default", "dialog:default"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop",
|
||||
"description": "Window control, the updater and process restart, none of which a phone has",
|
||||
"platforms": ["macOS", "windows", "linux"],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 491 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 829 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Margin Docs mark: a page with a margin rule. Glyph only, matching margin's logo-dark.svg conventions.
|
||||
App icon composition on a 512 canvas: squircle rect x=49 y=49 w=414 h=414 rx=92.5 fill #0d0c0a,
|
||||
then this glyph under transform="translate(56,48) scale(1.142857142857)".
|
||||
That scale puts every stroke centreline on a pixel centre at 32x32, and lifts the glyph 8px
|
||||
above the geometric centre for optical balance. -->
|
||||
<rect x="70" y="35" width="210" height="280" rx="14" stroke="#fcfbf7" stroke-width="10"/>
|
||||
<rect x="93" y="72" width="10" height="206" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="100" width="122" height="10" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="170" width="122" height="10" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="240" width="80" height="10" rx="5" fill="#fcfbf7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 915 B |
@@ -0,0 +1,185 @@
|
||||
// The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are
|
||||
// frozen once written: implementation modules add bodies, not fields.
|
||||
//
|
||||
// Types only. No `#[tauri::command]` lives here: the commands sit in the modules that implement
|
||||
// them and are registered in lib.rs.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One open folder. `id` is derived from the path, so it survives a relaunch and a root can be
|
||||
/// addressed without the frontend carrying the path around.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RootInfo {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
/// The folder's own name, which is what the sidebar heading shows.
|
||||
pub name: String,
|
||||
pub opened_ms: i64,
|
||||
}
|
||||
|
||||
/// A node in one root's tree, including the root itself. The whole tree is read in one go, so
|
||||
/// `children` being empty means a directory is empty, never that it is unexplored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileNode {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
/// dir | markdown | text | other
|
||||
pub kind: String,
|
||||
/// True for markdown and .txt, the two kinds that open in the editor. A directory is not
|
||||
/// editable either, so the greyed row in the tree is `kind == "other"` and not `!editable`.
|
||||
pub editable: bool,
|
||||
pub modified_ms: i64,
|
||||
#[serde(default)]
|
||||
pub children: Vec<FileNode>,
|
||||
}
|
||||
|
||||
/// `modified_ms` is the timestamp the text was read at. The frontend keeps it and hands it back
|
||||
/// on write, which is the only way it can tell its buffer apart from a file something else has
|
||||
/// touched since. Frontmatter is not split out here: the editor parses it, hides it and writes it
|
||||
/// back, so the backend only ever sees a whole document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResult {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
pub modified_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WriteResult {
|
||||
pub path: String,
|
||||
pub modified_ms: i64,
|
||||
/// The file moved on from the timestamp the caller expected and nothing was written. Not an
|
||||
/// error: the document is still open and still unsaved, and the user has to be asked which
|
||||
/// copy wins.
|
||||
pub conflict: bool,
|
||||
}
|
||||
|
||||
/// Where a pasted image landed. `rel_path` is what goes into the markdown link, relative to the
|
||||
/// document that received the paste; `path` is absolute, which is what the tree needs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetResult {
|
||||
pub path: String,
|
||||
pub rel_path: String,
|
||||
}
|
||||
|
||||
/// Payload of the `watch-event` event. `root` is a `RootInfo` id.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchEvent {
|
||||
pub root: String,
|
||||
pub path: String,
|
||||
/// created | modified | removed | renamed
|
||||
pub kind: String,
|
||||
/// Where the file was before a rename, absent on every other kind.
|
||||
#[serde(default)]
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Progress of the SQLite index, which lives in the app data directory and never in a user
|
||||
/// folder. Also the payload of the `index-progress` event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexStatus {
|
||||
/// idle | indexing | error
|
||||
pub phase: String,
|
||||
pub indexed: u32,
|
||||
pub total: u32,
|
||||
/// Epoch milliseconds of the last completed pass.
|
||||
pub last_indexed: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for IndexStatus {
|
||||
fn default() -> Self {
|
||||
IndexStatus {
|
||||
phase: "idle".to_string(),
|
||||
indexed: 0,
|
||||
total: 0,
|
||||
last_indexed: None,
|
||||
error: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-open offsets into whichever string the hit says they belong to, for highlighting.
|
||||
///
|
||||
/// The unit is a UTF-16 code unit, which is what a JavaScript string is indexed in and what the
|
||||
/// `slice` that draws the highlight counts. Not bytes, and deliberately not code points either:
|
||||
/// index.rs works in code points throughout and converts once at the boundary, in `to_utf16`,
|
||||
/// because the two agree on everything in the BMP and disagree by one per emoji, which is exactly
|
||||
/// the kind of difference that is invisible until somebody puts one in a filename.
|
||||
///
|
||||
/// `SpellIssue` counts differently on purpose. Its offsets address a ProseMirror document, and
|
||||
/// ProseMirror counts code points.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MatchRange {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// One quick-open result. `ranges` index into `rel_path`, which is also what the row shows, so a
|
||||
/// match on a folder name can be highlighted where it actually was.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuickOpenHit {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub root: String,
|
||||
pub rel_path: String,
|
||||
pub score: i32,
|
||||
#[serde(default)]
|
||||
pub ranges: Vec<MatchRange>,
|
||||
}
|
||||
|
||||
/// One full text result. `line` is one-based and counted over the file as it sits on disk,
|
||||
/// frontmatter included, so jumping to it lands in the right place. `ranges` index into `snippet`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchHit {
|
||||
pub path: String,
|
||||
pub root: String,
|
||||
pub title: String,
|
||||
pub line: u32,
|
||||
pub snippet: String,
|
||||
#[serde(default)]
|
||||
pub ranges: Vec<MatchRange>,
|
||||
}
|
||||
|
||||
/// A document that links here, shown at the end of the document it points at. Links between
|
||||
/// documents are relative markdown links, so a backlink is a resolved `](../thing.md)` and
|
||||
/// nothing more.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Backlink {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// One misspelling in a run of text handed to the checker.
|
||||
///
|
||||
/// `start` and `end` are half-open offsets in *characters*, not bytes and not UTF-16 units,
|
||||
/// because the other end is JavaScript addressing a ProseMirror document and ProseMirror counts
|
||||
/// in code points. macspell.rs does the conversion from the UTF-16 ranges AppKit answers in, and
|
||||
/// it is the only place in the app where that conversion is allowed to happen.
|
||||
///
|
||||
/// A word with no guesses is still an issue: NSSpellChecker regularly flags a typo it has no
|
||||
/// suggestion for, and dropping it because the menu would be empty is how a checker earns a
|
||||
/// reputation for missing things.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpellIssue {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub word: String,
|
||||
#[serde(default)]
|
||||
pub suggestions: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
// Rust owns the filesystem and nothing above it. Every byte that reaches or leaves the disk goes
|
||||
// through this module: opening a folder, walking it, reading a document, writing one back, sending
|
||||
// a file to the Trash, dropping a pasted image beside the document that received it, and the
|
||||
// SQLite index that answers the three questions a plain tree cannot. Markdown is never parsed
|
||||
// here; that is the bridge's job in TypeScript.
|
||||
//
|
||||
// Two promises constrain nearly every function below, and both are the product's rather than the
|
||||
// implementation's. Opening a folder or a file never writes anything, so nothing here may leave a
|
||||
// dotfile, a lock, a cache or a sidecar inside a folder the user opened. And every write is
|
||||
// atomic: a temp file beside the target, flushed, then renamed over it, so a crash or a full disk
|
||||
// can never leave a half written document where the user's document used to be.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering as Memory};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::dto::{
|
||||
AssetResult, Backlink, FileNode, IndexStatus, QuickOpenHit, ReadResult, RootInfo, SearchHit,
|
||||
WriteResult,
|
||||
};
|
||||
use crate::Roots;
|
||||
|
||||
/// Skipped whatever the folder's own gitignore says, because not one of the four is ever a
|
||||
/// document and a documents folder that happens to be a checkout should not open with its build
|
||||
/// output filling the sidebar.
|
||||
///
|
||||
/// The index walks by the same rule, so the sidebar and the search box agree about what a folder
|
||||
/// holds.
|
||||
pub(crate) const ALWAYS_SKIPPED: [&str; 4] = [".git", "node_modules", "target", "dist"];
|
||||
|
||||
/// These two mirror `src/model/doc.ts` and have to keep agreeing with it: the frontend decides
|
||||
/// from the extension whether a row opens in the editor, and `FileNode.editable` is that same
|
||||
/// decision made here.
|
||||
const MARKDOWN_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", "mkdn"];
|
||||
const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"];
|
||||
|
||||
/// Where the open folders are remembered between launches, inside the app data directory and never
|
||||
/// inside a folder the user opened.
|
||||
const ROOTS_FILE: &str = "roots.json";
|
||||
|
||||
/// What a pasted image is called when the clipboard suggests nothing usable.
|
||||
const FALLBACK_ASSET_NAME: &str = "image.png";
|
||||
|
||||
// Path validation. Every path below arrives as a string from the frontend, and the frontend is a
|
||||
// webview: a bug in a link resolver, a crafted document, a drag from somewhere unexpected or a
|
||||
// stale path belonging to a folder that has since been closed can all put an arbitrary string
|
||||
// here. This is the one place in the app where being wrong damages files the user never opened, so
|
||||
// the rule is deliberately blunt and every command that takes a path goes through it, reads as
|
||||
// well as writes.
|
||||
//
|
||||
// A path is accepted only when it holds no `..` component at all and, once symlinks have been
|
||||
// resolved, sits inside a folder that is currently open. Canonicalising first is what makes the
|
||||
// second half mean anything: without it both `~/notes/../../.ssh/id_rsa` and a symlink pointing at
|
||||
// /etc read as being inside the root. A path that does not exist yet is resolved against its
|
||||
// deepest existing ancestor and the remaining components are appended, so creating a file is
|
||||
// checked exactly as strictly as writing one. With no folder open nothing is inside a root, so
|
||||
// every path is rejected, which is the right default rather than an inconvenience.
|
||||
|
||||
fn path_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn ms_since_epoch(time: SystemTime) -> i64 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
ms_since_epoch(SystemTime::now())
|
||||
}
|
||||
|
||||
pub(crate) fn modified_ms(meta: &fs::Metadata) -> i64 {
|
||||
meta.modified().map(ms_since_epoch).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// True for a broken symlink too, which `Path::exists` is not. A name pointing at nothing is still
|
||||
/// a name that cannot be created.
|
||||
fn taken(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn kind_for(path: &Path, is_dir: bool) -> &'static str {
|
||||
if is_dir {
|
||||
return "dir";
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
match name.rfind('.') {
|
||||
Some(dot) if dot > 0 => {
|
||||
let ext = &name[dot + 1..];
|
||||
if MARKDOWN_EXTENSIONS.contains(&ext) {
|
||||
"markdown"
|
||||
} else if TEXT_EXTENSIONS.contains(&ext) {
|
||||
"text"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
fn node_from(path: &Path, is_dir: bool, modified: i64) -> FileNode {
|
||||
let kind = kind_for(path, is_dir);
|
||||
FileNode {
|
||||
path: path_string(path),
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path_string(path)),
|
||||
kind: kind.to_string(),
|
||||
editable: kind == "markdown" || kind == "text",
|
||||
modified_ms: modified,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_for(path: &Path) -> Result<FileNode, String> {
|
||||
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(node_from(path, meta.is_dir(), modified_ms(&meta)))
|
||||
}
|
||||
|
||||
/// A base name and not a path. `file_rename` cannot move anything, so a name carrying a separator
|
||||
/// is refused rather than quietly turned into a move.
|
||||
fn check_name(name: &str) -> Result<&str, String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
|
||||
return Err(format!("not a usable name: {name}"));
|
||||
}
|
||||
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
|
||||
return Err(format!("a name cannot contain a path separator: {name}"));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("path is not absolute: {}", path.display()));
|
||||
}
|
||||
if path.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return Err(format!("path contains a parent traversal: {}", path.display()));
|
||||
}
|
||||
let mut tail: Vec<OsString> = Vec::new();
|
||||
let mut cursor = path.to_path_buf();
|
||||
loop {
|
||||
if let Ok(base) = fs::canonicalize(&cursor) {
|
||||
let mut out = base;
|
||||
for part in tail.iter().rev() {
|
||||
out.push(part);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let name = cursor
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("cannot resolve path: {}", path.display()))?
|
||||
.to_os_string();
|
||||
tail.push(name);
|
||||
cursor = cursor
|
||||
.parent()
|
||||
.ok_or_else(|| format!("cannot resolve path: {}", path.display()))?
|
||||
.to_path_buf();
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate described above. `root_paths` are the folders the user has actually opened.
|
||||
pub fn resolve_in_roots(root_paths: &[String], raw: &str) -> Result<PathBuf, String> {
|
||||
let resolved = resolve(Path::new(raw))?;
|
||||
for root in root_paths {
|
||||
let base = match fs::canonicalize(root) {
|
||||
Ok(base) => base,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Component wise, so /notes-old is not read as being inside /notes.
|
||||
if resolved.starts_with(&base) {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(format!("path is outside every open folder: {raw}"))
|
||||
}
|
||||
|
||||
/// The lock is taken and dropped before any filesystem call, so a slow disk never blocks a command
|
||||
/// that only wants to know which folders are open.
|
||||
fn open_root_paths(roots: &State<'_, Roots>) -> Result<Vec<String>, String> {
|
||||
let open = roots.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(open.iter().map(|root| root.path.clone()).collect())
|
||||
}
|
||||
|
||||
fn checked(roots: &State<'_, Roots>, raw: &str) -> Result<PathBuf, String> {
|
||||
resolve_in_roots(&open_root_paths(roots)?, raw)
|
||||
}
|
||||
|
||||
/// One lock per document being written, so two saves of one file cannot interleave.
|
||||
///
|
||||
/// Keyed by the resolved path, because `/tmp/notes/a.md` and `/private/tmp/notes/a.md` are one
|
||||
/// document and two keys would be two locks and no mutual exclusion at all. An entry lives only
|
||||
/// while somebody holds it: every caller drops the locks nobody is using on the way in, so the map
|
||||
/// is the size of the writes in flight rather than of every document ever saved.
|
||||
static WRITE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Separates one temp name from the next inside this process, as the pid and the clock separate
|
||||
/// this process from any other.
|
||||
static WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Enough tries that a name collision has to be deliberate rather than unlucky.
|
||||
const TEMP_NAME_TRIES: u32 = 64;
|
||||
|
||||
fn write_lock_for(path: &Path) -> Arc<Mutex<()>> {
|
||||
let key = resolve(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let mut locks = match WRITE_LOCKS.lock() {
|
||||
Ok(locks) => locks,
|
||||
// The guarded value is `()`, so a writer that panicked left nothing half-built behind.
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
locks.retain(|_, held| Arc::strong_count(held) > 1);
|
||||
locks.entry(key).or_default().clone()
|
||||
}
|
||||
|
||||
/// A name for the temp file that no other write is using and no user is plausibly holding.
|
||||
///
|
||||
/// Beside the target, because a rename is only atomic within one filesystem. Hidden, so it is not
|
||||
/// mistaken for a document by the tree, by the watcher or by the person looking at the folder.
|
||||
/// Unique per call, because a name derived from the target alone is a name two concurrent saves
|
||||
/// both own and neither can safely delete. `.tmp` last so the watcher's transient rule catches it
|
||||
/// whatever the document happens to be called.
|
||||
fn temp_path(path: &Path) -> Result<PathBuf, String> {
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("cannot write {}: no folder to write in", path.display()))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("cannot write {}: no name to write to", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let pid = std::process::id();
|
||||
for _ in 0..TEMP_NAME_TRIES {
|
||||
let n = WRITE_SEQUENCE.fetch_add(1, Memory::Relaxed);
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let candidate = dir.join(format!(".{name}.{pid}-{n}-{stamp:x}.tmp"));
|
||||
// A name already on disk is somebody else's, and this call is the only thing allowed to
|
||||
// delete the name it picks.
|
||||
if !taken(&candidate) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(format!("cannot find a free temp name beside {}", path.display()))
|
||||
}
|
||||
|
||||
fn fill_temp(path: &Path, tmp: &Path, bytes: &[u8], existed: bool) -> Result<(), String> {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true);
|
||||
if existed {
|
||||
fs::copy(path, tmp).map_err(|e| e.to_string())?;
|
||||
options.truncate(true);
|
||||
} else {
|
||||
options.create_new(true);
|
||||
}
|
||||
let mut file = options.open(tmp).map_err(|e| e.to_string())?;
|
||||
file.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
file.sync_all().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` through a temp file beside it and a rename, which is atomic within a
|
||||
/// filesystem. At no instant does the target hold half a document: it holds every old byte or
|
||||
/// every new one, whatever happens in between, and that is what makes an autosaving editor safe
|
||||
/// against a crash or a full disk mid-write. A rename that fails has not happened, so the original
|
||||
/// is still whole and still where it was.
|
||||
///
|
||||
/// The temp file starts as a copy of the original rather than as an empty file. On macOS
|
||||
/// `fs::copy` carries permissions, ACLs and extended attributes across, and since the file the
|
||||
/// user is left with is the temp file, that copy is the only thing stopping a save from quietly
|
||||
/// dropping a Finder tag or the executable bit.
|
||||
///
|
||||
/// There is no `.bak` rotation, deliberately. This is the user's own markdown in the user's own
|
||||
/// folder, very often under version control, and the app is already holding the whole source
|
||||
/// string in memory and refusing to write when the mtime on disk has moved. A backup sibling buys
|
||||
/// none of that back, and a backup named after the target is a file the user may own themselves,
|
||||
/// which the rotation would unlink without asking and without the Trash. Nothing is deleted here
|
||||
/// but the temp file this call created.
|
||||
///
|
||||
/// Writes to one path are serialized. Two saves of one document, which is all a debounced autosave
|
||||
/// and a Cmd+S landing together are, would otherwise race between two renames and leave the
|
||||
/// document at neither name.
|
||||
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let lock = write_lock_for(path);
|
||||
let _held = match lock.lock() {
|
||||
Ok(held) => held,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
write_through_temp(path, bytes)
|
||||
}
|
||||
|
||||
fn write_through_temp(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let tmp = temp_path(path)?;
|
||||
let existed = taken(path);
|
||||
|
||||
// Both ends of the rename, before either is touched: the watcher sees the temp file appear and
|
||||
// the document change as two unrelated events, and either one getting through is the app's own
|
||||
// save coming back to the frontend as somebody else's edit.
|
||||
crate::watch::note_self_write(&tmp);
|
||||
crate::watch::note_self_write(path);
|
||||
|
||||
if let Err(e) = fill_temp(path, &tmp, bytes, existed) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Again, because the suppression is a window that started before the copy and the fsync, and on
|
||||
// a large document those are most of it.
|
||||
crate::watch::note_self_write(&tmp);
|
||||
crate::watch::note_self_write(path);
|
||||
|
||||
match fs::rename(&tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The untitled rule: `untitled.md`, then `untitled-2.md`, and never an overwrite. The suffix goes
|
||||
/// before the extension so the file keeps opening in the same app as the one it was named after.
|
||||
pub fn free_path(dir: &Path, name: &str) -> PathBuf {
|
||||
let first = dir.join(name);
|
||||
if !taken(&first) {
|
||||
return first;
|
||||
}
|
||||
let as_path = Path::new(name);
|
||||
let stem = as_path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| name.to_string());
|
||||
let ext = as_path.extension().map(|e| e.to_string_lossy().into_owned());
|
||||
let joined = |suffix: String| match &ext {
|
||||
Some(ext) => dir.join(format!("{stem}-{suffix}.{ext}")),
|
||||
None => dir.join(format!("{stem}-{suffix}")),
|
||||
};
|
||||
for n in 2..10_000u32 {
|
||||
let candidate = joined(n.to_string());
|
||||
if !taken(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
joined(now_ms().to_string())
|
||||
}
|
||||
|
||||
fn compare_nodes(a: &FileNode, b: &FileNode) -> Ordering {
|
||||
let a_dir = a.kind == "dir";
|
||||
let b_dir = b.kind == "dir";
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
}
|
||||
|
||||
fn assemble(
|
||||
path: &Path,
|
||||
nodes: &mut HashMap<PathBuf, FileNode>,
|
||||
children: &HashMap<PathBuf, Vec<PathBuf>>,
|
||||
) -> Option<FileNode> {
|
||||
let mut node = nodes.remove(path)?;
|
||||
if let Some(kids) = children.get(path) {
|
||||
let mut built: Vec<FileNode> = kids
|
||||
.iter()
|
||||
.filter_map(|kid| assemble(kid, nodes, children))
|
||||
.collect();
|
||||
built.sort_by(compare_nodes);
|
||||
node.children = built;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// One pass over a folder, returning the root node with everything under it already attached.
|
||||
///
|
||||
/// `show_ignored` turns off gitignore, the hidden file rule and the four always skipped folders in
|
||||
/// one go, for a settings toggle that lets a user see what the tree is holding back.
|
||||
pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
|
||||
let meta = fs::metadata(root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(format!("not a folder: {}", root.display()));
|
||||
}
|
||||
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder
|
||||
// A symlinked folder pointing back at one of its own ancestors would otherwise walk for
|
||||
// ever, and a documents folder is exactly where somebody keeps one.
|
||||
.follow_links(false)
|
||||
// A .gitignore is worth honouring whether or not the folder is a checkout: the user wrote
|
||||
// it about these files either way.
|
||||
.require_git(false)
|
||||
.standard_filters(!show_ignored);
|
||||
if !show_ignored {
|
||||
builder.filter_entry(|entry| {
|
||||
if entry.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
!ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
|
||||
});
|
||||
}
|
||||
|
||||
let mut nodes: HashMap<PathBuf, FileNode> = HashMap::new();
|
||||
let mut children: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
|
||||
for entry in builder.build() {
|
||||
// One unreadable entry is one missing row and not a failed tree. A documents folder can
|
||||
// easily hold something the user cannot stat, and losing the whole sidebar over it would
|
||||
// be a far worse answer than losing the row.
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = entry.path().to_path_buf();
|
||||
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
let modified = entry.metadata().map(|m| modified_ms(&m)).unwrap_or(0);
|
||||
if entry.depth() > 0 {
|
||||
if let Some(parent) = path.parent() {
|
||||
children
|
||||
.entry(parent.to_path_buf())
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
}
|
||||
nodes.insert(path.clone(), node_from(&path, is_dir, modified));
|
||||
}
|
||||
|
||||
assemble(root, &mut nodes, &children).ok_or_else(|| format!("cannot read {}", root.display()))
|
||||
}
|
||||
|
||||
pub fn read_document(path: &Path) -> Result<ReadResult, String> {
|
||||
// The mtime is taken before the read rather than after. Read the other way round and a change
|
||||
// landing between the two would be stamped onto older text, and the next save would overwrite
|
||||
// it believing it had seen it.
|
||||
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("not a file: {}", path.display()));
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(ReadResult {
|
||||
path: path_string(path),
|
||||
text,
|
||||
modified_ms: modified_ms(&meta),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_document(
|
||||
path: &Path,
|
||||
text: &str,
|
||||
expected_modified_ms: Option<i64>,
|
||||
) -> Result<WriteResult, String> {
|
||||
// A file that is gone falls through to the write. Recreating a document somebody deleted under
|
||||
// the user is not clobbering a change, and refusing would strand the buffer with nowhere to go.
|
||||
if let (Some(expected), Ok(meta)) = (expected_modified_ms, fs::metadata(path)) {
|
||||
let current = modified_ms(&meta);
|
||||
if current != expected {
|
||||
return Ok(WriteResult {
|
||||
path: path_string(path),
|
||||
modified_ms: current,
|
||||
conflict: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
atomic_write(path, text.as_bytes())?;
|
||||
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(WriteResult {
|
||||
path: path_string(path),
|
||||
modified_ms: modified_ms(&meta),
|
||||
conflict: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_file(parent: &Path, name: &str) -> Result<FileNode, String> {
|
||||
let name = check_name(name)?;
|
||||
if !parent.is_dir() {
|
||||
return Err(format!("not a folder: {}", parent.display()));
|
||||
}
|
||||
let target = free_path(parent, name);
|
||||
// create_new rather than a check and then a create: the whole point of the untitled rule is
|
||||
// that nothing is ever overwritten, and another process can take the name between the two.
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&target)
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
node_for(&target)
|
||||
}
|
||||
|
||||
pub fn create_folder(parent: &Path, name: &str) -> Result<FileNode, String> {
|
||||
let name = check_name(name)?;
|
||||
if !parent.is_dir() {
|
||||
return Err(format!("not a folder: {}", parent.display()));
|
||||
}
|
||||
let target = free_path(parent, name);
|
||||
fs::create_dir(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
node_for(&target)
|
||||
}
|
||||
|
||||
pub fn rename_entry(path: &Path, name: &str) -> Result<FileNode, String> {
|
||||
let name = check_name(name)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("cannot rename {}", path.display()))?;
|
||||
let target = parent.join(name);
|
||||
if target == path {
|
||||
return node_for(path);
|
||||
}
|
||||
// On a case insensitive volume a case only rename finds the file being renamed already sitting
|
||||
// at the target, which is not a collision.
|
||||
if taken(&target) && fs::canonicalize(&target).ok() != fs::canonicalize(path).ok() {
|
||||
return Err(format!("already exists: {}", target.display()));
|
||||
}
|
||||
fs::rename(path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
node_for(&target)
|
||||
}
|
||||
|
||||
pub fn move_entry(path: &Path, dest_dir: &Path) -> Result<FileNode, String> {
|
||||
if !dest_dir.is_dir() {
|
||||
return Err(format!("not a folder: {}", dest_dir.display()));
|
||||
}
|
||||
if dest_dir.starts_with(path) {
|
||||
return Err(format!("cannot move {} inside itself", path.display()));
|
||||
}
|
||||
if path.parent() == Some(dest_dir) {
|
||||
// Already there. Going on would hand it a free name and leave two of it.
|
||||
return node_for(path);
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("cannot move {}", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let target = free_path(dest_dir, &name);
|
||||
if fs::rename(path, &target).is_ok() {
|
||||
return node_for(&target);
|
||||
}
|
||||
// A rename cannot cross a volume, so the move becomes a copy and a trip to the Trash. Never a
|
||||
// remove: if anything about this went wrong the original is still recoverable in Finder.
|
||||
copy_tree(path, &target)?;
|
||||
trash_entry(path)?;
|
||||
node_for(&target)
|
||||
}
|
||||
|
||||
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
|
||||
let meta = fs::symlink_metadata(src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
if !meta.is_dir() {
|
||||
return fs::copy(src, dest)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("{}: {e}", dest.display()));
|
||||
}
|
||||
fs::create_dir(dest).map_err(|e| format!("{}: {e}", dest.display()))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
copy_tree(&entry.path(), &dest.join(entry.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn duplicate_entry(path: &Path) -> Result<FileNode, String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("cannot duplicate {}", path.display()))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("cannot duplicate {}", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let target = free_path(parent, &name);
|
||||
copy_tree(path, &target)?;
|
||||
node_for(&target)
|
||||
}
|
||||
|
||||
/// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the
|
||||
/// reason one of them is gone for good.
|
||||
pub fn trash_entry(path: &Path) -> Result<(), String> {
|
||||
trash::delete(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn write_asset(doc_path: &Path, bytes: &[u8], name: &str) -> Result<AssetResult, String> {
|
||||
let dir = doc_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("cannot place an image beside {}", doc_path.display()))?;
|
||||
// The clipboard suggests the name, so it is a suggestion and not a path: only the last
|
||||
// component of it is ever used.
|
||||
let suggested = Path::new(name)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.filter(|n| check_name(n).is_ok())
|
||||
.unwrap_or_else(|| FALLBACK_ASSET_NAME.to_string());
|
||||
|
||||
let assets = dir.join("assets");
|
||||
if taken(&assets) {
|
||||
if !assets.is_dir() {
|
||||
return Err(format!("not a folder: {}", assets.display()));
|
||||
}
|
||||
} else {
|
||||
fs::create_dir_all(&assets).map_err(|e| format!("{}: {e}", assets.display()))?;
|
||||
}
|
||||
|
||||
let target = free_path(&assets, &suggested);
|
||||
atomic_write(&target, bytes)?;
|
||||
let file = target
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or(suggested);
|
||||
Ok(AssetResult {
|
||||
path: path_string(&target),
|
||||
rel_path: format!("assets/{file}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A root id is a hash of the path and of nothing else, so the same folder is the same root after
|
||||
/// a relaunch and the frontend can address one without carrying its path around. FNV-1a rather
|
||||
/// than the standard hasher, whose output is only promised to be stable within one build.
|
||||
pub fn root_id_for(path: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in path.as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
fn roots_file(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(crate::library::app_data_dir(app)?.join(ROOTS_FILE))
|
||||
}
|
||||
|
||||
fn load_roots(app: &AppHandle) -> Vec<RootInfo> {
|
||||
let Ok(file) = roots_file(app) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(text) = fs::read_to_string(file) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_str(&text).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_roots(app: &AppHandle, roots: &[RootInfo]) -> Result<(), String> {
|
||||
let file = roots_file(app)?;
|
||||
let text = serde_json::to_string_pretty(roots).map_err(|e| e.to_string())?;
|
||||
atomic_write(&file, text.as_bytes())
|
||||
}
|
||||
|
||||
/// Every folder currently open, in the order they were opened, which is the order the sidebar
|
||||
/// lists them in.
|
||||
///
|
||||
/// The list outlives a relaunch, so the first call after launch reads it back from the app data
|
||||
/// directory and fills the managed state from it. A root whose folder has since been deleted,
|
||||
/// renamed or unmounted is dropped rather than handed back as a row that cannot be expanded.
|
||||
#[tauri::command]
|
||||
pub fn roots_list(app: AppHandle, roots: State<'_, Roots>) -> Result<Vec<RootInfo>, String> {
|
||||
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
|
||||
if open.is_empty() {
|
||||
*open = load_roots(&app);
|
||||
}
|
||||
let before = open.len();
|
||||
open.retain(|root| Path::new(&root.path).is_dir());
|
||||
if open.len() != before {
|
||||
save_roots(&app, &open)?;
|
||||
}
|
||||
Ok(open.clone())
|
||||
}
|
||||
|
||||
/// Adds `path` to the open roots and returns it. Idempotent: opening a folder that is already open
|
||||
/// returns the entry that is already there rather than a second copy of it.
|
||||
///
|
||||
/// `id` is derived from the path and from nothing else, so the same folder is the same root across
|
||||
/// relaunches and the frontend can address a root without carrying its path around. Opening a
|
||||
/// folder never writes anything into it, and that includes not creating it: a `path` that is not
|
||||
/// an existing directory is an error, not a mkdir.
|
||||
#[tauri::command]
|
||||
pub fn root_open(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result<RootInfo, String> {
|
||||
let canonical = fs::canonicalize(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err(format!("not a folder: {}", canonical.display()));
|
||||
}
|
||||
let path = path_string(&canonical);
|
||||
let id = root_id_for(&path);
|
||||
|
||||
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
|
||||
if open.is_empty() {
|
||||
*open = load_roots(&app);
|
||||
}
|
||||
if let Some(existing) = open.iter().find(|root| root.id == id) {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let info = RootInfo {
|
||||
id,
|
||||
name: canonical
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.clone()),
|
||||
path,
|
||||
opened_ms: now_ms(),
|
||||
};
|
||||
open.push(info.clone());
|
||||
save_roots(&app, &open)?;
|
||||
// The lock goes before the index hears about the folder: the indexer's first move is to ask
|
||||
// `Roots` where that root is, and it should not have to wait for this command to return.
|
||||
drop(open);
|
||||
// Scanned now rather than at the next rebuild, or a folder just opened would answer nothing at
|
||||
// all to a search until something else asked for a full pass.
|
||||
crate::index::scan_root(&app, info.clone());
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Forgets a root and persists the shorter list. Touches nothing inside the folder itself.
|
||||
///
|
||||
/// Stopping the watcher is not done here. The frontend calls `watch_stop` for the same root, which
|
||||
/// keeps this module from having to know that the watcher exists.
|
||||
#[tauri::command]
|
||||
pub fn root_close(app: AppHandle, roots: State<'_, Roots>, root_id: String) -> Result<(), String> {
|
||||
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
|
||||
let before = open.len();
|
||||
open.retain(|root| root.id != root_id);
|
||||
if open.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
save_roots(&app, &open)?;
|
||||
drop(open);
|
||||
// The rows go with the folder. Nothing can be opened from a search result that belongs to a
|
||||
// folder that is no longer there to open it in.
|
||||
crate::index::forget_root(&app, &root_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The whole tree for one root in a single pass, the root node itself included. Empty `children`
|
||||
/// therefore means an empty directory, never one that has not been explored yet.
|
||||
///
|
||||
/// Gitignore aware through the `ignore` crate, and `.git` itself is skipped too: a documents folder
|
||||
/// under version control should not surface its own ignored build output as if it were documents.
|
||||
/// Everything else is returned, including files the editor cannot open, because the tree greys
|
||||
/// those rows out rather than hiding them. Children come back sorted directories first and then by
|
||||
/// name, case insensitively, so the tree does not reshuffle itself between two reads of an
|
||||
/// unchanged folder.
|
||||
#[tauri::command(async)]
|
||||
pub fn tree_read(roots: State<'_, Roots>, root_id: String) -> Result<FileNode, String> {
|
||||
let path = roots.path_for(&root_id)?;
|
||||
scan_tree(Path::new(&path), false)
|
||||
}
|
||||
|
||||
/// Opens Finder with the file selected, rather than opening the file.
|
||||
#[tauri::command]
|
||||
pub fn reveal_in_finder(
|
||||
app: AppHandle,
|
||||
roots: State<'_, Roots>,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let path = checked(&roots, &path)?;
|
||||
app.opener()
|
||||
.reveal_item_in_dir(&path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Hands a file to whatever macOS opens it with. This is the only way a non editable file in the
|
||||
/// tree can be opened at all, so it has to work for anything, not just for documents.
|
||||
#[tauri::command]
|
||||
pub fn open_external(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result<(), String> {
|
||||
let path = checked(&roots, &path)?;
|
||||
app.opener()
|
||||
.open_path(path_string(&path), None::<&str>)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads a document as UTF-8, and reads nothing else: no metadata is written, no lock is taken and
|
||||
/// no sidecar appears beside it.
|
||||
///
|
||||
/// `modified_ms` is the file's mtime as it was at the moment of the read. The caller keeps it and
|
||||
/// hands it back on write, which is the only thing that can tell an unsaved buffer apart from a
|
||||
/// file another program has touched since. A file that is not valid UTF-8 is an error rather than
|
||||
/// a lossy conversion, because a lossy read followed by a save would corrupt the user's file.
|
||||
#[tauri::command(async)]
|
||||
pub fn file_read(roots: State<'_, Roots>, path: String) -> Result<ReadResult, String> {
|
||||
read_document(&checked(&roots, &path)?)
|
||||
}
|
||||
|
||||
/// Writes a document atomically: a temp file in the same directory, flushed and synced, then
|
||||
/// renamed over the target. The old bytes survive a crash, a full disk and a power cut mid-write.
|
||||
///
|
||||
/// `expected_modified_ms` is the mtime the caller last saw. If the file has moved on from it,
|
||||
/// nothing is written and the result carries `conflict`, which is not an error: the document is
|
||||
/// still open, still unsaved, and the user is the one who decides which copy wins. `None` means
|
||||
/// write regardless, which is what a first save of a new file does.
|
||||
///
|
||||
/// Permissions, ownership and any extended attributes of the original survive the rename, since
|
||||
/// the file the user ends up with is the temp file and it must not arrive with different bits.
|
||||
///
|
||||
/// The index is told directly rather than through the watcher. `watch::note_self_write` drops the
|
||||
/// app's own writes out of the watch stream so an autosave does not come back as somebody else's
|
||||
/// edit, which means the one document the watcher never reports is the one the user is working in.
|
||||
/// Without this line the only version of it the index would ever hold is the one from before they
|
||||
/// started typing.
|
||||
#[tauri::command(async)]
|
||||
pub fn file_write(
|
||||
app: AppHandle,
|
||||
roots: State<'_, Roots>,
|
||||
path: String,
|
||||
text: String,
|
||||
expected_modified_ms: Option<i64>,
|
||||
) -> Result<WriteResult, String> {
|
||||
let path = checked(&roots, &path)?;
|
||||
let result = write_document(&path, &text, expected_modified_ms)?;
|
||||
// A conflict wrote nothing, and whatever moved the file on is an outside change the watcher
|
||||
// does report.
|
||||
if !result.conflict {
|
||||
crate::index::note_write(&app, &path);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Creates an empty file inside `parent_path`. `name` is a suggestion: a name already taken gets a
|
||||
/// suffix, and the node that comes back carries the name that was really used, so the caller never
|
||||
/// has to guess at it or race another process for it.
|
||||
#[tauri::command]
|
||||
pub fn file_create(
|
||||
roots: State<'_, Roots>,
|
||||
parent_path: String,
|
||||
name: String,
|
||||
) -> Result<FileNode, String> {
|
||||
create_file(&checked(&roots, &parent_path)?, &name)
|
||||
}
|
||||
|
||||
/// Creates an empty directory inside `parent_path`, under the same suggested-name rule as
|
||||
/// `file_create`.
|
||||
#[tauri::command]
|
||||
pub fn file_folder_create(
|
||||
roots: State<'_, Roots>,
|
||||
parent_path: String,
|
||||
name: String,
|
||||
) -> Result<FileNode, String> {
|
||||
create_folder(&checked(&roots, &parent_path)?, &name)
|
||||
}
|
||||
|
||||
/// Renames a file or folder where it stands. `name` is a base name and not a path: a `name` holding
|
||||
/// a path separator is an error, because this command cannot move anything and quietly doing so
|
||||
/// would be worse than refusing.
|
||||
///
|
||||
/// This is the only thing that changes a document's identity, and it happens because the user asked
|
||||
/// for it. Nothing in this app renames a file on its own, least of all because a heading changed.
|
||||
#[tauri::command]
|
||||
pub fn file_rename(
|
||||
roots: State<'_, Roots>,
|
||||
path: String,
|
||||
name: String,
|
||||
) -> Result<FileNode, String> {
|
||||
rename_entry(&checked(&roots, &path)?, &name)
|
||||
}
|
||||
|
||||
/// Moves a file or folder into `dest_dir`, keeping its name unless that name is taken there.
|
||||
///
|
||||
/// This command moves bytes and nothing else. The relative links a move breaks are rewritten a
|
||||
/// layer up, in src/linkRewrite.ts, which splices one destination at a time into the file's own
|
||||
/// text and never hands a document to the serializer, so a file whose links did not move is not
|
||||
/// written at all.
|
||||
#[tauri::command]
|
||||
pub fn file_move(
|
||||
roots: State<'_, Roots>,
|
||||
path: String,
|
||||
dest_dir: String,
|
||||
) -> Result<FileNode, String> {
|
||||
let open = open_root_paths(&roots)?;
|
||||
let path = resolve_in_roots(&open, &path)?;
|
||||
let dest_dir = resolve_in_roots(&open, &dest_dir)?;
|
||||
move_entry(&path, &dest_dir)
|
||||
}
|
||||
|
||||
/// Copies a file, or a folder and everything under it, beside itself under a free name. The copy is
|
||||
/// byte for byte: nothing is parsed, normalised or reformatted on the way through.
|
||||
#[tauri::command(async)]
|
||||
pub fn file_duplicate(roots: State<'_, Roots>, path: String) -> Result<FileNode, String> {
|
||||
duplicate_entry(&checked(&roots, &path)?)
|
||||
}
|
||||
|
||||
/// Sends a file or folder to the system Trash through the `trash` crate, never `remove_file`. These
|
||||
/// are the user's own documents and this app does not get to be the reason one of them is gone for
|
||||
/// good, so a delete is always something Finder can undo.
|
||||
#[tauri::command(async)]
|
||||
pub fn file_trash(roots: State<'_, Roots>, path: String) -> Result<(), String> {
|
||||
trash_entry(&checked(&roots, &path)?)
|
||||
}
|
||||
|
||||
/// Writes a pasted image into an `assets/` folder beside the document that received the paste,
|
||||
/// creating that folder when it is not already there. Images are the only thing other than markdown
|
||||
/// this app ever puts inside a user's folder.
|
||||
///
|
||||
/// `name` is what the clipboard suggested, which is usually `image.png` and usually already taken,
|
||||
/// so a taken name gets a suffix. `rel_path` in the result is what goes into the markdown link,
|
||||
/// relative to the document, so the folder stays movable and shareable as a whole.
|
||||
#[tauri::command(async)]
|
||||
pub fn asset_write(
|
||||
roots: State<'_, Roots>,
|
||||
doc_path: String,
|
||||
bytes: Vec<u8>,
|
||||
name: String,
|
||||
) -> Result<AssetResult, String> {
|
||||
write_asset(&checked(&roots, &doc_path)?, &bytes, &name)
|
||||
}
|
||||
|
||||
// The SQLite index, which lives in the app data directory and never inside a folder the user
|
||||
// opened. It is derived state rather than a source of truth: every row is rebuilt from the files on
|
||||
// disk, so deleting the database costs nothing but the time to walk the open roots again. It is
|
||||
// kept current from the same debounced batch the watcher already sends the frontend, plus one call
|
||||
// in `file_write` for the app's own saves, which are the changes that batch deliberately never
|
||||
// mentions.
|
||||
//
|
||||
// Everything below is a handful of lines because the index itself is a module of its own: these are
|
||||
// the commands, and index.rs is the database.
|
||||
|
||||
/// Rescans every open root from scratch and returns the status the pass started with. Progress
|
||||
/// arrives on the `index-progress` event, because a full rescan of a large folder outlives any one
|
||||
/// command.
|
||||
#[tauri::command(async)]
|
||||
pub fn index_rebuild(app: AppHandle, roots: State<'_, Roots>) -> Result<IndexStatus, String> {
|
||||
// The roots are read here rather than on the indexer's thread, so the pass covers the folders
|
||||
// that were open when the user asked for it and not whatever the list has become since.
|
||||
let open = roots.0.lock().map_err(|e| e.to_string())?.clone();
|
||||
crate::index::rebuild(&app, open)
|
||||
}
|
||||
|
||||
/// Where the index has got to, for the status line. Cheap enough to poll and safe to call before
|
||||
/// any indexing has ever run.
|
||||
#[tauri::command]
|
||||
pub fn index_status(app: AppHandle) -> Result<IndexStatus, String> {
|
||||
crate::index::status(&app)
|
||||
}
|
||||
|
||||
/// Fuzzy match over paths relative to their root, across every open root, best score first.
|
||||
///
|
||||
/// `ranges` index into `rel_path`, which is also the string the row shows, so a match on a folder
|
||||
/// name is highlighted where it really was. They are character offsets and not byte offsets,
|
||||
/// because the other end is JavaScript and highlights by character.
|
||||
#[tauri::command(async)]
|
||||
pub fn search_quick_open(
|
||||
app: AppHandle,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Result<Vec<QuickOpenHit>, String> {
|
||||
crate::index::quick_open(&app, &query, limit)
|
||||
}
|
||||
|
||||
/// Full text search across every open root through FTS5.
|
||||
///
|
||||
/// `line` is one based and counted over the file as it sits on disk, frontmatter included, so
|
||||
/// jumping to a hit lands on the line the user can see in any other editor. `ranges` index into
|
||||
/// `snippet`, again by character.
|
||||
#[tauri::command(async)]
|
||||
pub fn search_text(app: AppHandle, query: String, limit: u32) -> Result<Vec<SearchHit>, String> {
|
||||
crate::index::search(&app, &query, limit)
|
||||
}
|
||||
|
||||
/// Every document holding a relative markdown link that resolves to `path`.
|
||||
///
|
||||
/// This is a reverse lookup over links that are already in the files. Nothing is written anywhere
|
||||
/// to make a backlink exist, and a document with no incoming links simply has none.
|
||||
#[tauri::command(async)]
|
||||
pub fn backlinks_for(app: AppHandle, path: String) -> Result<Vec<Backlink>, String> {
|
||||
crate::index::backlinks(&app, &path)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
pub mod dto;
|
||||
pub mod fs;
|
||||
pub mod index;
|
||||
mod library;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macspell;
|
||||
pub mod spell;
|
||||
pub mod watch;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::dto::RootInfo;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||
#[cfg(desktop)]
|
||||
use tauri::{Emitter, Runtime};
|
||||
|
||||
/// The open folders, in the order they were opened.
|
||||
///
|
||||
/// This lives here rather than in either module because both need it and neither owns the other:
|
||||
/// `fs` puts roots in and takes them out, `watch` only ever turns an id back into a path. It is the
|
||||
/// in-memory copy of the list; persisting it across a relaunch is `fs`'s business.
|
||||
#[derive(Default)]
|
||||
pub struct Roots(pub Mutex<Vec<RootInfo>>);
|
||||
|
||||
impl Roots {
|
||||
/// The absolute path of an open root. Every command that takes a `rootId` needs this before it
|
||||
/// can touch anything, and an id that is not open is an error rather than an empty result.
|
||||
pub fn path_for(&self, id: &str) -> Result<String, String> {
|
||||
let roots = self.0.lock().map_err(|e| e.to_string())?;
|
||||
roots
|
||||
.iter()
|
||||
.find(|root| root.id == id)
|
||||
.map(|root| root.path.clone())
|
||||
.ok_or_else(|| format!("no such root: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let menu = Menu::default(handle)?;
|
||||
|
||||
let open_folder = MenuItemBuilder::with_id("open-folder", "Open Folder…")
|
||||
.accelerator("CmdOrCtrl+O")
|
||||
.build(handle)?;
|
||||
let new_doc = MenuItemBuilder::with_id("new-doc", "New Document")
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(handle)?;
|
||||
let new_folder = MenuItemBuilder::with_id("new-folder", "New Folder").build(handle)?;
|
||||
let quick_open = MenuItemBuilder::with_id("quick-open", "Quick Open…")
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
.build(handle)?;
|
||||
let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette…")
|
||||
.accelerator("CmdOrCtrl+K")
|
||||
.build(handle)?;
|
||||
let save = MenuItemBuilder::with_id("save", "Save")
|
||||
.accelerator("CmdOrCtrl+S")
|
||||
.build(handle)?;
|
||||
let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?;
|
||||
let check_updates =
|
||||
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
|
||||
let settings = MenuItemBuilder::with_id("settings", "Settings…")
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(handle)?;
|
||||
let find = MenuItemBuilder::with_id("find", "Find…")
|
||||
.accelerator("CmdOrCtrl+F")
|
||||
.build(handle)?;
|
||||
let find_in_files = MenuItemBuilder::with_id("find-in-files", "Find in Files…")
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(handle)?;
|
||||
let report_issue =
|
||||
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
|
||||
|
||||
let submenus: Vec<_> = menu
|
||||
.items()?
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
MenuItemKind::Submenu(submenu) => Some(submenu),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let find_submenu = |name: &str| {
|
||||
submenus
|
||||
.iter()
|
||||
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
|
||||
.cloned()
|
||||
};
|
||||
|
||||
match find_submenu("File") {
|
||||
Some(submenu) => {
|
||||
submenu.prepend_items(&[
|
||||
&open_folder,
|
||||
&new_doc,
|
||||
&new_folder,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&quick_open,
|
||||
&command_palette,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&save,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&close_folder,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
])?;
|
||||
}
|
||||
None => {
|
||||
let submenu = SubmenuBuilder::new(handle, "File")
|
||||
.item(&open_folder)
|
||||
.item(&new_doc)
|
||||
.item(&new_folder)
|
||||
.item(&PredefinedMenuItem::separator(handle)?)
|
||||
.item(&quick_open)
|
||||
.item(&command_palette)
|
||||
.item(&PredefinedMenuItem::separator(handle)?)
|
||||
.item(&save)
|
||||
.item(&PredefinedMenuItem::separator(handle)?)
|
||||
.item(&close_folder)
|
||||
.build()?;
|
||||
menu.insert(&submenu, 1)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(edit) = find_submenu("Edit") {
|
||||
edit.append_items(&[
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&find,
|
||||
&find_in_files,
|
||||
])?;
|
||||
}
|
||||
|
||||
if let Some(help) = find_submenu("Help") {
|
||||
help.append_items(&[&report_issue])?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_submenu) = submenus.first() {
|
||||
app_submenu.insert(&check_updates, 1)?;
|
||||
app_submenu.insert(&settings, 3)?;
|
||||
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
|
||||
}
|
||||
if let Some(view) = find_submenu("View") {
|
||||
let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar")
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
.build(handle)?;
|
||||
view.prepend_items(&[&toggle_sidebar, &PredefinedMenuItem::separator(handle)?])?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
if let Some(file) = find_submenu("File") {
|
||||
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
|
||||
}
|
||||
if let Some(edit) = find_submenu("Edit") {
|
||||
edit.append_items(&[&settings])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let context = tauri::generate_context!();
|
||||
|
||||
#[cfg_attr(mobile, allow(unused_mut))]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(Roots::default())
|
||||
.manage(watch::Watchers::default())
|
||||
.manage(index::Index::default());
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_process::init());
|
||||
if context.config().plugins.0.contains_key("updater") {
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
}
|
||||
}
|
||||
|
||||
builder = builder.setup(|app| {
|
||||
if let Err(e) = library::app_data_dir(app.handle()) {
|
||||
eprintln!("failed to prepare app data dir: {e}");
|
||||
}
|
||||
// The index is opened here rather than lazily on the first search, because opening it is
|
||||
// where a schema migration runs and a migration that fails should say so at launch rather
|
||||
// than the first time somebody presses Cmd+P. A failure is not fatal: the app is a text
|
||||
// editor with a broken search box, which is worth far more than a window that will not
|
||||
// open.
|
||||
if let Err(e) = index::open(app.handle()) {
|
||||
eprintln!("failed to open the search index: {e}");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder
|
||||
.menu(|handle| build_menu(handle))
|
||||
.on_menu_event(|app, event| {
|
||||
if matches!(
|
||||
event.id().0.as_str(),
|
||||
"open-folder"
|
||||
| "new-doc"
|
||||
| "new-folder"
|
||||
| "save"
|
||||
| "close-folder"
|
||||
| "settings"
|
||||
| "find"
|
||||
| "find-in-files"
|
||||
| "quick-open"
|
||||
| "command-palette"
|
||||
| "toggle-sidebar"
|
||||
| "check-updates"
|
||||
| "report-issue"
|
||||
) {
|
||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The whole command surface, in the order dto.rs describes it. Registering a command is this
|
||||
// file's job alone: a module adds a body, never a line here.
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
fs::roots_list,
|
||||
fs::root_open,
|
||||
fs::root_close,
|
||||
fs::tree_read,
|
||||
fs::reveal_in_finder,
|
||||
fs::open_external,
|
||||
fs::file_read,
|
||||
fs::file_write,
|
||||
fs::file_create,
|
||||
fs::file_folder_create,
|
||||
fs::file_rename,
|
||||
fs::file_move,
|
||||
fs::file_duplicate,
|
||||
fs::file_trash,
|
||||
fs::asset_write,
|
||||
watch::watch_start,
|
||||
watch::watch_stop,
|
||||
fs::index_rebuild,
|
||||
fs::index_status,
|
||||
fs::search_quick_open,
|
||||
fs::search_text,
|
||||
fs::backlinks_for,
|
||||
spell::spell_check,
|
||||
spell::spell_learn,
|
||||
spell::spell_unlearn,
|
||||
spell::spell_available,
|
||||
])
|
||||
.run(context)
|
||||
.expect("error while running Margin Docs");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// The one file in this app that talks to AppKit, and the whole of spelling on macOS.
|
||||
//
|
||||
// Spelling is NSSpellChecker's rather than this app's. It is the same shared checker Mail, Notes
|
||||
// and TextEdit correct into, so a word learned anywhere on the machine is a word this editor does
|
||||
// not underline, the user's own configured languages come along for free, and nothing here ships a
|
||||
// dictionary or holds an opinion about English. There is no custom word list beside it either:
|
||||
// learning a word teaches it to the system, which is where every other Mac app puts it.
|
||||
//
|
||||
// Three things make this more than a one line binding.
|
||||
//
|
||||
// Offsets are the first. AppKit answers in NSRange, which counts UTF-16 code units, and the caller
|
||||
// is a ProseMirror document, which counts code points. The two agree exactly until the paragraph
|
||||
// holds an emoji or anything else outside the basic plane, and from that character onwards every
|
||||
// later offset in the run is out by one per astral character. An underline drawn from a UTF-16
|
||||
// offset onto a code point document sits under the wrong word, and a suggestion applied at that
|
||||
// offset replaces the wrong characters, which is a silent edit to the user's file. `utf16_to_codepoint`
|
||||
// is the whole of the fix, and this file is the only place in the app allowed to do that conversion
|
||||
// so that there is exactly one thing to keep right.
|
||||
//
|
||||
// Which results to keep is the second. The checker is asked for Spelling and Link together and
|
||||
// only the spelling results are returned. Link earns its place in the request because it makes the
|
||||
// checker treat a URL as one span: without it `https://github.com/some-repo` is a run of tokens
|
||||
// none of which are in any dictionary, and a paragraph carrying a link comes back with half of it
|
||||
// underlined.
|
||||
//
|
||||
// The main thread is the third, and the answer is that none of this needs it. objc2 asks for a
|
||||
// `MainThreadMarker` on exactly the panel accessors of NSSpellChecker (`spellingPanel`,
|
||||
// `accessoryView`, `substitutionsPanel`), which this file never touches. The checking and learning
|
||||
// calls carry no such requirement, and since each of them round trips to the system spell service
|
||||
// over XPC, the caller deliberately runs them off the main thread.
|
||||
|
||||
use objc2::rc::autoreleasepool;
|
||||
use objc2_app_kit::NSSpellChecker;
|
||||
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
|
||||
|
||||
use crate::dto::SpellIssue;
|
||||
|
||||
/// A context menu is a menu, not a dictionary page. The checker will happily offer thirty guesses
|
||||
/// and the ones past the first few are noise the user has to read past to reach Learn Spelling.
|
||||
const MAX_SUGGESTIONS: usize = 5;
|
||||
|
||||
/// Zero as the spell document tag, everywhere below. A tag buys a per-document session the checker
|
||||
/// remembers ignored words against, and this app has no Ignore: a word is either learned for good
|
||||
/// or it stays underlined, so there is no session to allocate.
|
||||
const NO_DOCUMENT: isize = 0;
|
||||
|
||||
/// UTF-16 offset to code point offset, one entry per code unit of `text` plus a terminal entry, so
|
||||
/// both ends of a half-open range are a lookup and neither is a special case.
|
||||
///
|
||||
/// A character outside the basic plane occupies two code units and one code point, so both of its
|
||||
/// units map to the same code point index. An NSRange landing in the middle of a surrogate pair,
|
||||
/// which the checker will not produce, therefore resolves to the start of that character rather
|
||||
/// than to a position that does not exist.
|
||||
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
|
||||
let mut map = Vec::with_capacity(utf16_len + 1);
|
||||
let mut cp = 0;
|
||||
for ch in text.chars() {
|
||||
for _ in 0..ch.len_utf16() {
|
||||
map.push(cp);
|
||||
}
|
||||
cp += 1;
|
||||
}
|
||||
map.push(cp);
|
||||
map
|
||||
}
|
||||
|
||||
/// Every misspelling in one run of text, with half-open offsets in characters counted from the
|
||||
/// start of that run.
|
||||
///
|
||||
/// The run is not split into words here. NSSpellChecker does that better than any rule this app
|
||||
/// could write: it knows about contractions, hyphenation, proper nouns, capitalisation and
|
||||
/// whichever languages the user has turned on, and it decides where a word begins in each of them.
|
||||
pub fn check(text: &str) -> Vec<SpellIssue> {
|
||||
autoreleasepool(|_| {
|
||||
let checker = NSSpellChecker::sharedSpellChecker();
|
||||
let ns = NSString::from_str(text);
|
||||
let len = ns.length();
|
||||
let results = unsafe {
|
||||
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
|
||||
&ns,
|
||||
NSRange {
|
||||
location: 0,
|
||||
length: len,
|
||||
},
|
||||
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
|
||||
None,
|
||||
NO_DOCUMENT,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
let map = utf16_to_codepoint(text, len);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut issues = Vec::new();
|
||||
for result in results.iter() {
|
||||
// Link results were asked for so the checker would recognise a URL as one span, not so
|
||||
// that anything would be reported about them.
|
||||
if result.resultType() != NSTextCheckingType::Spelling {
|
||||
continue;
|
||||
}
|
||||
let range = result.range();
|
||||
// Clamped to the length the map was built from. A range past the end would index out
|
||||
// of it and panic, and a panic here takes down a command the frontend runs on every
|
||||
// keystroke.
|
||||
let start = map[range.location.min(len)];
|
||||
let end = map[range.location.saturating_add(range.length).min(len)];
|
||||
|
||||
// The word comes back out of `text` rather than from the checker, so the string the
|
||||
// frontend matches against is byte for byte the one it sent.
|
||||
let word: String = chars[start..end].iter().collect();
|
||||
|
||||
// Asked in the checker's own coordinates, because this range indexes into `ns`.
|
||||
let mut suggestions = Vec::new();
|
||||
if let Some(guesses) = checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(
|
||||
range,
|
||||
&ns,
|
||||
None,
|
||||
NO_DOCUMENT,
|
||||
) {
|
||||
for guess in guesses.iter() {
|
||||
suggestions.push(guess.to_string());
|
||||
if suggestions.len() >= MAX_SUGGESTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reported even with nothing to suggest. NSSpellChecker regularly flags a typo it has
|
||||
// no guess for, and dropping those because the menu would have no replacements in it
|
||||
// is how a checker earns a reputation for missing things.
|
||||
issues.push(SpellIssue {
|
||||
start,
|
||||
end,
|
||||
word,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
issues
|
||||
})
|
||||
}
|
||||
|
||||
/// Teaches `word` to the system, for every app on this machine and not only for this one.
|
||||
///
|
||||
/// That is not a shortcut, it is what a checker borrowed from the OS does: `learnWord:` hands the
|
||||
/// word to the system spell service, exactly where the "Learn Spelling" item in Mail or Pages puts
|
||||
/// it, and every app on the machine stops underlining it from then on. This app deliberately keeps
|
||||
/// no private word list beside that, because a second dictionary the rest of the system cannot see
|
||||
/// is a word the user has to teach twice.
|
||||
pub fn learn(word: &str) {
|
||||
autoreleasepool(|_| {
|
||||
NSSpellChecker::sharedSpellChecker().learnWord(&NSString::from_str(word));
|
||||
})
|
||||
}
|
||||
|
||||
/// Undoes a `learn`, for a word taught by a slip of the hand. Also system wide, and the checker
|
||||
/// treats unlearning a word it was never taught as nothing to do rather than as an error.
|
||||
pub fn unlearn(word: &str) {
|
||||
autoreleasepool(|_| {
|
||||
NSSpellChecker::sharedSpellChecker().unlearnWord(&NSString::from_str(word));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
margin_docs_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// The four spelling commands, and the only place in this crate that knows whether the machine has
|
||||
// a checker at all.
|
||||
//
|
||||
// Everything real happens in macspell.rs, which is compiled on macOS alone. This module exists so
|
||||
// that the frontend gets the same four commands on every platform: it picks a checker at compile
|
||||
// time and each command below has one body rather than a cfg in the middle of it.
|
||||
//
|
||||
// A run with no misspellings and a build with no checker both answer with an empty list, and that
|
||||
// is deliberate. Returning an error from `spell_check` on a platform without NSSpellChecker would
|
||||
// put a permanent failure toast in front of a user whose actual situation is "this build cannot
|
||||
// check spelling", which is not a failure and is not something they can act on. `spell_available`
|
||||
// is where that fact belongs, because it is the one answer the UI can do something with: it hides
|
||||
// the underlines and the menu rather than offering a menu that does nothing.
|
||||
//
|
||||
// There is no state here and no dictionary file. The system holds the learned words, so there is
|
||||
// nothing for this module to load at launch, nothing to keep in sync and nothing to migrate.
|
||||
|
||||
use crate::dto::SpellIssue;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::macspell as checker;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use self::no_checker as checker;
|
||||
|
||||
/// Spelling on a platform this app has no system checker for: every call succeeds and does
|
||||
/// nothing. The alternative is a cfg inside each of the three commands that touch a checker, and
|
||||
/// three chances to get the non-macOS answer subtly different from each other.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod no_checker {
|
||||
use crate::dto::SpellIssue;
|
||||
|
||||
pub fn check(_text: &str) -> Vec<SpellIssue> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn learn(_word: &str) {}
|
||||
|
||||
pub fn unlearn(_word: &str) {}
|
||||
}
|
||||
|
||||
/// Every misspelling in one run of text.
|
||||
///
|
||||
/// Offsets are half-open and counted in characters from the start of the run that was passed in,
|
||||
/// never from the start of a document. The checker is told about a paragraph and answers about that
|
||||
/// paragraph; it has no idea a document exists, which is what leaves the caller free to send a
|
||||
/// paragraph, a visible screenful or one sentence, and to add its own base offset afterwards.
|
||||
///
|
||||
/// Runs off the main thread. The call reaches the system spell service over XPC and a long
|
||||
/// paragraph is enough work that a window held still for the length of it would be visible, which
|
||||
/// matters more here than elsewhere because this is called while the user is typing.
|
||||
#[tauri::command(async)]
|
||||
pub fn spell_check(text: String) -> Result<Vec<SpellIssue>, String> {
|
||||
Ok(checker::check(&text))
|
||||
}
|
||||
|
||||
/// Teaches a word to the system dictionary, for every app on the machine and not only for this
|
||||
/// one.
|
||||
///
|
||||
/// That is the honest behaviour of a checker borrowed from the OS, and it is exactly what the
|
||||
/// "Learn Spelling" item in every other Mac app does. This app ships no dictionary of its own and
|
||||
/// keeps no private word list, so there is nowhere else for the word to go and nothing that would
|
||||
/// need teaching twice.
|
||||
///
|
||||
/// A blank word is nothing to learn rather than an error: the frontend takes the word from
|
||||
/// whatever the user right clicked, and an empty selection is a mis-click, not a failure worth a
|
||||
/// toast.
|
||||
#[tauri::command(async)]
|
||||
pub fn spell_learn(word: String) -> Result<(), String> {
|
||||
let word = word.trim();
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
checker::learn(word);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undoes a `spell_learn`, for a word taught by a slip of the hand. System wide in the same way,
|
||||
/// and unlearning a word that was never learned is nothing to do rather than an error.
|
||||
#[tauri::command(async)]
|
||||
pub fn spell_unlearn(word: String) -> Result<(), String> {
|
||||
let word = word.trim();
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
checker::unlearn(word);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this build has a checker behind it. Answered from the target rather than by asking
|
||||
/// AppKit anything: NSSpellChecker is part of macOS itself, so on a build that has it there is no
|
||||
/// failure mode where it is absent, and on any other build there is nothing to ask.
|
||||
#[tauri::command]
|
||||
pub fn spell_available() -> Result<bool, String> {
|
||||
Ok(cfg!(target_os = "macos"))
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// One filesystem watcher per open root. Changes never come back as a return value: each debounced
|
||||
// batch is emitted as a `watch-event`, so a file another program touched reaches the frontend the
|
||||
// same way whether anything asked for it or not.
|
||||
//
|
||||
// Debounced because one logical change is a burst of raw events. A git checkout rewrites a hundred
|
||||
// files, another editor's atomic save is a create, a rename and a remove for what the user thinks
|
||||
// of as one save, and a folder copied in arrives file by file. Reacting to raw events would reload
|
||||
// the open document several times over for a single save somewhere else.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use notify::event::{ModifyKind, RenameMode};
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode};
|
||||
use notify_debouncer_full::{
|
||||
new_debouncer_opt, DebounceEventResult, DebouncedEvent, Debouncer, NoCache,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::dto::WatchEvent;
|
||||
use crate::Roots;
|
||||
|
||||
/// Mirrors `WATCH_EVENT` in src/ipc.ts.
|
||||
const WATCH_EVENT: &str = "watch-event";
|
||||
|
||||
/// How long a burst of raw events for one path is allowed to settle before it is reported.
|
||||
///
|
||||
/// Long enough that an atomic save arrives as one batch rather than as its create, rename and
|
||||
/// remove parts, short enough that a file changed by another program shows up while the user is
|
||||
/// still looking at the window that changed it.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How long a path stays on the self-written list.
|
||||
///
|
||||
/// The event for a write cannot reach the callback sooner than `DEBOUNCE` after the write finishes,
|
||||
/// and the debouncer's tick is a further `DEBOUNCE / 4`, so nothing under about 375ms would suppress
|
||||
/// anything at all. The rest is headroom for the write itself: an fsync on a large document on a
|
||||
/// busy disk can take a good fraction of a second, and the path is registered before the write
|
||||
/// starts, not after. Two seconds leaves room for that several times over, and the cost of
|
||||
/// overshooting is bounded and mild.
|
||||
///
|
||||
/// What that cost is: an external change to a file the app itself wrote less than two seconds ago is
|
||||
/// dropped. That is the right answer anyway. The only way to be inside that window is for the user
|
||||
/// to be typing in that document right now, and a reload mid-keystroke would throw away their
|
||||
/// unsaved text to show them somebody else's. The next save catches it regardless, because
|
||||
/// `file_write` compares mtimes and reports a conflict. Erring the other way is not symmetric: a
|
||||
/// leaked echo of the app's own autosave reloads the editor under the cursor on every save, which
|
||||
/// makes the app unusable rather than briefly out of date.
|
||||
///
|
||||
/// Entries expire on time and are not consumed on the first match, because one atomic save can
|
||||
/// produce several debounced events for the same path and suppressing only the first would defeat
|
||||
/// the whole thing.
|
||||
const SELF_WRITE_WINDOW: Duration = Duration::from_millis(2_000);
|
||||
|
||||
/// How long after a change was raised a file may have been born and still count as created by it.
|
||||
///
|
||||
/// Covers the write landing, the backend noticing and the timestamp's own granularity. Too tight
|
||||
/// and a new file is reported as a modification of a file the tree has never heard of; too loose
|
||||
/// and editing a file made moments ago is reported as making it again.
|
||||
const BIRTH_SLACK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Paths this app wrote, and when.
|
||||
///
|
||||
/// A global rather than managed state because the commands that write files take a path and nothing
|
||||
/// else: their signatures are the frozen contract, so there is no `State` for them to reach the
|
||||
/// watcher through. Keyed by the path with its directory resolved, since that is the only form both
|
||||
/// sides can agree on.
|
||||
static SELF_WRITES: LazyLock<Mutex<HashMap<PathBuf, Instant>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Records that this app is about to write `path`, so the watcher drops the event that comes back.
|
||||
///
|
||||
/// Call it immediately before every write, for every path the write touches. An atomic save touches
|
||||
/// two, the temp file and the target it is renamed over, and each end raises its own events, so
|
||||
/// registering only the target lets the temp file's half through on its own. `fs::atomic_write` is
|
||||
/// the one caller, and every write in the app goes through it.
|
||||
///
|
||||
/// Cheap, so a caller unsure whether a path will really be written should register it anyway. The
|
||||
/// entry expires on its own and registering a path that is never written costs one map slot for two
|
||||
/// seconds.
|
||||
pub fn note_self_write<P: AsRef<Path>>(path: P) {
|
||||
let key = resolve(path.as_ref());
|
||||
let now = Instant::now();
|
||||
if let Ok(mut writes) = SELF_WRITES.lock() {
|
||||
writes.retain(|_, at| now.duration_since(*at) < SELF_WRITE_WINDOW);
|
||||
writes.insert(key, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live watchers, keyed by root id.
|
||||
///
|
||||
/// Dropping a debouncer stops its thread, so both `watch_stop` and closing a folder come down to a
|
||||
/// remove from this map and nothing else. The map is the only place a watcher is held: a watcher
|
||||
/// that is not in here is not running.
|
||||
#[derive(Default)]
|
||||
pub struct Watchers(pub Mutex<HashMap<String, Debouncer<RecommendedWatcher, NoCache>>>);
|
||||
|
||||
/// Starts watching one open root, recursively. Idempotent: starting a watch that is already running
|
||||
/// is a no-op rather than a second watcher on the same folder.
|
||||
///
|
||||
/// Every debounced change is emitted as one `watch-event` carrying the root id, so the frontend can
|
||||
/// tell which tree to patch without matching path prefixes, and no path is ever the subject of more
|
||||
/// than one event per batch.
|
||||
///
|
||||
/// A rename is two events on macOS and not one: a `removed` for the name that went and a `created`
|
||||
/// or `modified` for the name that arrived. FSEvents describes the two ends as unrelated changes and
|
||||
/// nothing here can prove otherwise, so `old_path` stays empty and a frontend that wants to follow a
|
||||
/// renamed document has to pair them up itself, or rely on `file_rename` for the renames it made.
|
||||
/// `created` and `modified` are likewise a hint rather than a promise, since the only thing
|
||||
/// separating them is how recently the file was born: both mean the row should be inserted or
|
||||
/// refreshed. `removed` is exact, because it is a fact about the disk read at the moment of
|
||||
/// emitting.
|
||||
///
|
||||
/// The app's own writes are filtered out, on the strength of the paths `note_self_write` was told
|
||||
/// about. The frontend cannot do this itself: by the time it hears about a change it has already
|
||||
/// been handed a path and a reason to reload, and the mtime it holds cannot tell it apart from a
|
||||
/// write another program made in the same second.
|
||||
///
|
||||
/// The search index reads the same batch, which makes that filter its blind spot: the one document
|
||||
/// this stream never mentions is the one the user is typing in, because that is the one this app
|
||||
/// keeps saving. `fs::file_write` tells the index about its own saves for exactly that reason.
|
||||
///
|
||||
/// The root going away takes the watcher with it. A folder deleted or moved out from under a
|
||||
/// running watch emits one `removed` for the root path and then the watcher is dropped, since a
|
||||
/// watch on a path that no longer exists reports nothing and would sit in the map forever.
|
||||
#[tauri::command]
|
||||
pub fn watch_start(
|
||||
app: AppHandle,
|
||||
roots: State<'_, Roots>,
|
||||
watchers: State<'_, Watchers>,
|
||||
root_id: String,
|
||||
) -> Result<(), String> {
|
||||
let root_path = roots.path_for(&root_id)?;
|
||||
|
||||
let mut live = watchers.0.lock().map_err(|e| e.to_string())?;
|
||||
if live.contains_key(&root_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let handle = app.clone();
|
||||
let dead_root = root_path.clone();
|
||||
let dead_id = root_id.clone();
|
||||
let debouncer = spawn_watcher(root_id.clone(), root_path, move |events| {
|
||||
for event in &events {
|
||||
handle.emit(WATCH_EVENT, event).ok();
|
||||
}
|
||||
// The index reads the same batch the frontend does, so a file another program wrote is
|
||||
// searchable at the same moment the tree learns about it. It goes here rather than inside
|
||||
// `spawn_watcher` because that function is also what the tests drive, with a real folder and
|
||||
// no app at all to hold an index.
|
||||
crate::index::note_watch_events(&handle, &events);
|
||||
if events
|
||||
.iter()
|
||||
.any(|event| event.kind == "removed" && event.path == dead_root)
|
||||
{
|
||||
reap(handle.clone(), dead_id.clone());
|
||||
}
|
||||
})?;
|
||||
|
||||
live.insert(root_id, debouncer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the watcher for one root and drops it. Stopping a watch that is not running is a no-op, so
|
||||
/// the frontend can close a folder and stop its watcher without having to remember whether it ever
|
||||
/// started one.
|
||||
#[tauri::command]
|
||||
pub fn watch_stop(watchers: State<'_, Watchers>, root_id: String) -> Result<(), String> {
|
||||
let mut live = watchers.0.lock().map_err(|e| e.to_string())?;
|
||||
live.remove(&root_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Watches `root_path` recursively and hands each debounced batch to `sink` as `watch-event`
|
||||
/// payloads. The watch runs until the returned debouncer is dropped.
|
||||
///
|
||||
/// `watch_start` is a thin wrapper over this: everything above the Tauri event lives here so the
|
||||
/// tests can drive a real watcher over a real folder without an app to emit into.
|
||||
pub fn spawn_watcher<F>(
|
||||
root_id: String,
|
||||
root_path: String,
|
||||
sink: F,
|
||||
) -> Result<Debouncer<RecommendedWatcher, NoCache>, String>
|
||||
where
|
||||
F: Fn(Vec<WatchEvent>) + Send + 'static,
|
||||
{
|
||||
let root = PathBuf::from(&root_path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("not a folder: {root_path}"));
|
||||
}
|
||||
let canonical = std::fs::canonicalize(&root).map_err(|e| e.to_string())?;
|
||||
|
||||
let watched = canonical.clone();
|
||||
let handler = move |result: DebounceEventResult| {
|
||||
let batch = match result {
|
||||
Ok(batch) => batch,
|
||||
Err(errors) => {
|
||||
for error in errors {
|
||||
eprintln!("watch error under {}: {error}", watched.display());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let events = watch_events(&batch, &root_id, &watched, &root);
|
||||
if !events.is_empty() {
|
||||
sink(events);
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately without the file id cache the crate would otherwise pick. Its whole job is to
|
||||
// recognise the two halves of a rename by inode, and on macOS it does more harm than good: it
|
||||
// decides the halves belong together, folds the old name's events into the new name's queue,
|
||||
// and then throws away the rename event that carried the old name, because FSEvents claims the
|
||||
// old name was created. What comes out is a modification of the new path and no word at all
|
||||
// that the old path is gone, which leaves a row in the tree for a file that no longer exists.
|
||||
// With no cache the two halves stay separate and both ends get reported.
|
||||
let mut debouncer: Debouncer<RecommendedWatcher, NoCache> = new_debouncer_opt(
|
||||
DEBOUNCE,
|
||||
None,
|
||||
handler,
|
||||
NoCache::new(),
|
||||
notify::Config::default(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debouncer
|
||||
.watch(&canonical, RecursiveMode::Recursive)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
/// Drops a root's watcher from another thread.
|
||||
///
|
||||
/// The call site is inside the debouncer's own callback, and dropping a debouncer from the thread it
|
||||
/// is calling you on is asking for a join on yourself. One short-lived thread is the whole fix.
|
||||
fn reap(app: AppHandle, root_id: String) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(watchers) = app.try_state::<Watchers>() {
|
||||
if let Ok(mut live) = watchers.0.lock() {
|
||||
live.remove(&root_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One debounced batch turned into the events the frontend sees: classified, filtered and reduced
|
||||
/// to at most one event per path.
|
||||
fn watch_events(
|
||||
batch: &[DebouncedEvent],
|
||||
root_id: &str,
|
||||
canonical_root: &Path,
|
||||
root_path: &Path,
|
||||
) -> Vec<WatchEvent> {
|
||||
let mut events: Vec<WatchEvent> = Vec::new();
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for event in batch {
|
||||
// The kernel dropped events under load and the backend is telling us so. Nothing in the
|
||||
// batch describes what was missed, so the honest answer is to report the root as changed
|
||||
// and let the frontend read the tree again.
|
||||
let next = if event.need_rescan() {
|
||||
WatchEvent {
|
||||
root: root_id.to_string(),
|
||||
path: root_path.to_string_lossy().into_owned(),
|
||||
kind: "modified".to_string(),
|
||||
old_path: None,
|
||||
}
|
||||
} else {
|
||||
let Some((kind, path, old_path)) = classify(event) else {
|
||||
continue;
|
||||
};
|
||||
// The root itself is exempt from the transient rule: a folder called `.notes` is a
|
||||
// perfectly good root, and its own removal is the one event nothing under it can
|
||||
// describe.
|
||||
if was_self_written(&path)
|
||||
|| old_path.as_deref().is_some_and(was_self_written)
|
||||
|| (path.as_path() != canonical_root && is_transient(&path))
|
||||
|| is_hidden_below(&path, canonical_root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
WatchEvent {
|
||||
root: root_id.to_string(),
|
||||
path: rebase(&path, canonical_root, root_path),
|
||||
kind: kind.to_string(),
|
||||
old_path: old_path.map(|path| rebase(&path, canonical_root, root_path)),
|
||||
}
|
||||
};
|
||||
|
||||
merge(&mut events, &mut index, next);
|
||||
}
|
||||
|
||||
// The root itself going away is the one change nothing under it can describe. macOS does report
|
||||
// it as an event on the watched path, but a folder moved rather than emptied is a single rename
|
||||
// this side may never see, so the state of the folder is checked rather than waited for.
|
||||
if !canonical_root.exists() {
|
||||
merge(
|
||||
&mut events,
|
||||
&mut index,
|
||||
WatchEvent {
|
||||
root: root_id.to_string(),
|
||||
path: root_path.to_string_lossy().into_owned(),
|
||||
kind: "removed".to_string(),
|
||||
old_path: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn merge(events: &mut Vec<WatchEvent>, index: &mut HashMap<String, usize>, next: WatchEvent) {
|
||||
match index.get(&next.path) {
|
||||
// A later `modified` says nothing a create or a rename in the same batch has not already
|
||||
// said, and would lose that event's `old_path`. Anything else supersedes.
|
||||
Some(_) if next.kind == "modified" => {}
|
||||
Some(&at) => events[at] = next,
|
||||
None => {
|
||||
index.insert(next.path.clone(), events.len());
|
||||
events.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind, the path it happened to and, for a rename, where the file was before.
|
||||
///
|
||||
/// Almost nothing here comes from the event's own kind, and that is deliberate. FSEvents does not
|
||||
/// describe a change, it describes a file: every event it reports for a path carries the union of
|
||||
/// everything that has ever happened to that path, so `ItemCreated` is set on the modification of a
|
||||
/// file that was created last week and on the deletion of one created a second ago. Trusting it
|
||||
/// would report every save as a create and, once the debouncer has folded a create and a remove
|
||||
/// together, every deletion as a modification.
|
||||
///
|
||||
/// So the file itself is asked instead. The event says which path changed, which is the one thing
|
||||
/// FSEvents is reliable about, and a stat at the moment of emitting says what it changed into. That
|
||||
/// is also fresher than the flags: by the time a batch comes out it is at least a debounce window
|
||||
/// old, and what is on disk now is what the frontend is about to go and read.
|
||||
fn classify(event: &DebouncedEvent) -> Option<(&'static str, PathBuf, Option<PathBuf>)> {
|
||||
let first = event.paths.first()?.clone();
|
||||
|
||||
// The one thing a stat cannot answer afterwards is where a file used to be, so a rename the
|
||||
// debouncer managed to stitch back together is read from the event. macOS never gets here: it
|
||||
// reports the two ends of a rename as unrelated events and the pairing is left to the inode
|
||||
// cache this watcher deliberately does without. A backend that names both ends itself, which
|
||||
// inotify does through the rename cookie, still arrives whole.
|
||||
if let EventKind::Modify(ModifyKind::Name(RenameMode::Both)) = &event.kind {
|
||||
let to = event.paths.get(1)?.clone();
|
||||
if is_transient(&to) || !to.exists() {
|
||||
return Some(("removed", first, None));
|
||||
}
|
||||
if is_transient(&first) {
|
||||
// Another editor saving the way this one does: a temp file renamed over the target.
|
||||
// Reporting the temp name as the document's previous name would have the frontend go
|
||||
// looking for a tree row that never existed.
|
||||
return Some((appearance(event, &to), to, None));
|
||||
}
|
||||
return Some(("renamed", to, Some(first)));
|
||||
}
|
||||
|
||||
if matches!(&event.kind, EventKind::Access(_) | EventKind::Other) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((appearance(event, &first), first, None))
|
||||
}
|
||||
|
||||
/// What is at the path now: `created`, `modified` or `removed`.
|
||||
///
|
||||
/// Created and modified are told apart by the file's birth time, since nothing else survives to
|
||||
/// here. A file born within a slack of when the event was raised was born by the change the event
|
||||
/// describes; anything older was only touched by it. The slack covers the gap between the write
|
||||
/// landing and the backend seeing it, and erring towards `created` is the cheaper mistake: both
|
||||
/// kinds mean the same thing to a tree that inserts or refreshes a row, and only `removed` means
|
||||
/// something a frontend must not get wrong.
|
||||
fn appearance(event: &DebouncedEvent, path: &Path) -> &'static str {
|
||||
let Ok(meta) = std::fs::symlink_metadata(path) else {
|
||||
return "removed";
|
||||
};
|
||||
let Ok(born) = meta.created() else {
|
||||
return "modified";
|
||||
};
|
||||
let happened = SystemTime::now().checked_sub(event.time.elapsed());
|
||||
match (born.checked_add(BIRTH_SLACK), happened) {
|
||||
(Some(fresh_until), Some(happened)) if fresh_until >= happened => "created",
|
||||
_ => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
/// A name no tree row will ever carry: an editor's lock file, swap file or backup, or the temp file
|
||||
/// half of somebody's atomic save.
|
||||
///
|
||||
/// Every path in a batch goes through this, not only the two ends of a rename the debouncer managed
|
||||
/// to stitch together. macOS never reports a rename whole, so the branch in `classify` that used to
|
||||
/// be the only caller never ran there, and the temp file of every save in the folder was reported to
|
||||
/// the frontend as a document appearing and then vanishing.
|
||||
///
|
||||
/// This app's own temp file, `.<name>.<unique>.tmp`, is caught twice over, by the leading dot and by
|
||||
/// the extension. That is on purpose: `note_self_write` already covers it, and a save that somehow
|
||||
/// outran its two second window should still not put a temp name in front of the user.
|
||||
fn is_transient(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
name.starts_with('.')
|
||||
|| name.ends_with('~')
|
||||
|| name.ends_with(".tmp")
|
||||
|| name.ends_with(".swp")
|
||||
|| name.ends_with(".swx")
|
||||
}
|
||||
|
||||
/// Whether the path sits under a dot-directory or is a dotfile, counted from the root down.
|
||||
///
|
||||
/// The tree hides those, so reporting them would be reporting changes to rows that do not exist:
|
||||
/// `.git` alone would fire hundreds of times for one checkout. Counted from the root and not from
|
||||
/// `/` because a root may perfectly well be a folder inside `~/.config`, and that folder's contents
|
||||
/// are not hidden from anybody.
|
||||
fn is_hidden_below(path: &Path, root: &Path) -> bool {
|
||||
let Ok(rel) = path.strip_prefix(root) else {
|
||||
return false;
|
||||
};
|
||||
rel.components()
|
||||
.any(|part| part.as_os_str().to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
/// The path as the frontend knows it: under the root exactly as `Roots` spells it.
|
||||
///
|
||||
/// FSEvents reports resolved paths, so a root opened as `/tmp/notes` comes back as
|
||||
/// `/private/tmp/notes` and every path the frontend holds would fail to match.
|
||||
fn rebase(path: &Path, canonical_root: &Path, root_path: &Path) -> String {
|
||||
match path.strip_prefix(canonical_root) {
|
||||
Ok(rel) if rel.as_os_str().is_empty() => root_path.to_string_lossy().into_owned(),
|
||||
Ok(rel) => root_path.join(rel).to_string_lossy().into_owned(),
|
||||
Err(_) => path.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path with its directory resolved through any symlink, which is the form event paths arrive in.
|
||||
///
|
||||
/// The directory and not the path itself, because the file about to be written may not exist yet and
|
||||
/// there is nothing to canonicalise. macOS alone makes this necessary: `/tmp` and `/var` are
|
||||
/// symlinks into `/private`, so a document under either would never match the event describing it.
|
||||
fn resolve(path: &Path) -> PathBuf {
|
||||
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
match std::fs::canonicalize(parent) {
|
||||
Ok(dir) => dir.join(name),
|
||||
Err(_) => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Event paths arrive already resolved, because the watch is placed on the canonicalised root, so
|
||||
/// they can be looked up as they are.
|
||||
fn was_self_written(path: &Path) -> bool {
|
||||
let Ok(writes) = SELF_WRITES.lock() else {
|
||||
return false;
|
||||
};
|
||||
writes
|
||||
.get(path)
|
||||
.is_some_and(|at| Instant::now().duration_since(*at) < SELF_WRITE_WINDOW)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Margin Docs",
|
||||
"version": "0.0.1",
|
||||
"identifier": "studio.margin.docs",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1440",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "",
|
||||
"width": 1360,
|
||||
"height": 900,
|
||||
"minWidth": 880,
|
||||
"minHeight": 600,
|
||||
"titleBarStyle": "Overlay"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"shortDescription": "A folder of markdown documents",
|
||||
"longDescription": "Margin Docs is an editor for a folder of markdown documents.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "REPLACE_WITH_TAURI_SIGNER_PUBKEY",
|
||||
"endpoints": [
|
||||
"https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
// The filesystem layer is the only part of this app that can lose somebody's work, so the tests
|
||||
// here are about the promises rather than the plumbing: a failed write leaves the old file whole,
|
||||
// a conflict writes nothing at all, a name is never taken from a file that already has it, a path
|
||||
// from the frontend cannot reach outside the folders the user opened, and a delete is always
|
||||
// something Finder can undo.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use margin_docs_lib::dto::FileNode;
|
||||
use margin_docs_lib::fs::{
|
||||
atomic_write, create_file, create_folder, duplicate_entry, free_path, move_entry, read_document,
|
||||
rename_entry, resolve_in_roots, root_id_for, scan_tree, trash_entry, write_asset,
|
||||
write_document,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn root() -> TempDir {
|
||||
TempDir::new().expect("a temp dir")
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn names(node: &FileNode) -> Vec<String> {
|
||||
node.children.iter().map(|c| c.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn find<'a>(node: &'a FileNode, name: &str) -> Option<&'a FileNode> {
|
||||
node.children.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
fn flat(node: &FileNode, into: &mut Vec<String>) {
|
||||
into.push(node.name.clone());
|
||||
for child in &node.children {
|
||||
flat(child, into);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_original_whole() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "the original");
|
||||
|
||||
// A folder that cannot be written to makes the write fail at its first step, which is the
|
||||
// moment the original is most at risk. The temp name cannot be blocked from outside any more,
|
||||
// since it is unique per call, so the folder is what gets taken away instead.
|
||||
let open = fs::metadata(dir.path()).unwrap().permissions();
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
|
||||
|
||||
let result = atomic_write(&doc, b"the replacement");
|
||||
|
||||
fs::set_permissions(dir.path(), open).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_to_string(&doc).unwrap(), "the original");
|
||||
}
|
||||
|
||||
/// A `.bak` or a `.tmp` named after a document is a file somebody may have written on purpose, and
|
||||
/// a save of the document it is named after is not permission to unlink it.
|
||||
#[test]
|
||||
fn a_write_leaves_the_users_own_bak_and_tmp_siblings_alone() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
let bak = dir.path().join("doc.md.bak");
|
||||
let tmp = dir.path().join("doc.md.tmp");
|
||||
write(&doc, "one");
|
||||
write(&bak, "a revision the author kept on purpose");
|
||||
write(&tmp, "a scratch file the author kept on purpose");
|
||||
|
||||
atomic_write(&doc, b"two").unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&bak).unwrap(),
|
||||
"a revision the author kept on purpose",
|
||||
"the save deleted a backup the user owned"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&tmp).unwrap(),
|
||||
"a scratch file the author kept on purpose",
|
||||
"the save deleted a scratch file the user owned"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing named after the document may appear beside it, not even for the length of one save.
|
||||
/// Anything else watching the folder, git included, sees whatever is there while the write runs, and
|
||||
/// a crash halfway through strands it for good.
|
||||
#[test]
|
||||
fn a_save_puts_nothing_document_shaped_beside_the_document() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "one");
|
||||
|
||||
let watched = dir.path().to_path_buf();
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
let poller = std::thread::spawn(move || {
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
while stop_rx.try_recv().is_err() {
|
||||
if let Ok(entries) = fs::read_dir(&watched) {
|
||||
for entry in entries.flatten() {
|
||||
seen.insert(entry.file_name().to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
});
|
||||
|
||||
// Big enough that the copy, the write and the fsync are a window a poller can see into.
|
||||
atomic_write(&doc, &vec![b'z'; 32 * 1024 * 1024]).unwrap();
|
||||
stop_tx.send(()).ok();
|
||||
let seen = poller.join().unwrap();
|
||||
|
||||
let strays: Vec<&String> = seen
|
||||
.iter()
|
||||
.filter(|name| name.as_str() != "doc.md")
|
||||
.filter(|name| !(name.starts_with(".doc.md.") && name.ends_with(".tmp")))
|
||||
.collect();
|
||||
assert!(strays.is_empty(), "a save put these beside the document: {strays:?}");
|
||||
assert!(
|
||||
seen.iter().any(|name| name.starts_with(".doc.md.")),
|
||||
"the poller never caught the temp file, so this proved nothing: {seen:?}"
|
||||
);
|
||||
|
||||
let left: Vec<String> = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(left, vec!["doc.md".to_string()]);
|
||||
}
|
||||
|
||||
/// A debounced autosave and a Cmd+S both in flight are two saves of one document. Naming the temp
|
||||
/// file after the target made them race on one path, and the loser took the document with it.
|
||||
#[test]
|
||||
fn concurrent_saves_of_one_document_all_land_and_none_loses_it() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "the original");
|
||||
|
||||
let payloads: Vec<String> = (0..4)
|
||||
.map(|n| format!("save number {n}\n{}\n", "z".repeat(1024 * 1024)))
|
||||
.collect();
|
||||
let writers: Vec<_> = payloads
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|text| {
|
||||
let doc = doc.clone();
|
||||
std::thread::spawn(move || atomic_write(&doc, text.as_bytes()))
|
||||
})
|
||||
.collect();
|
||||
for writer in writers {
|
||||
writer
|
||||
.join()
|
||||
.expect("a writer thread")
|
||||
.expect("every concurrent save succeeds");
|
||||
}
|
||||
|
||||
let landed = fs::read_to_string(&doc).expect("the document is still there");
|
||||
assert!(
|
||||
payloads.contains(&landed),
|
||||
"the document holds neither writer's text"
|
||||
);
|
||||
let left: Vec<String> = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(left, vec!["doc.md".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_leaves_no_temp_and_no_backup_behind() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "one");
|
||||
|
||||
atomic_write(&doc, b"two").unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
|
||||
let mut left: Vec<String> = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(left, vec!["doc.md".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_keeps_the_permissions_the_file_had() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "one");
|
||||
fs::set_permissions(&doc, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
atomic_write(&doc, b"two").unwrap();
|
||||
|
||||
let mode = fs::metadata(&doc).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_writes_nothing() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "what is on disk");
|
||||
let seen = read_document(&doc).unwrap();
|
||||
|
||||
let result = write_document(&doc, "what the buffer holds", Some(seen.modified_ms - 5_000))
|
||||
.expect("a conflict is a result and not an error");
|
||||
|
||||
assert!(result.conflict);
|
||||
assert_eq!(fs::read_to_string(&doc).unwrap(), "what is on disk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timestamp_the_caller_saw_lets_the_write_through() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "one");
|
||||
let seen = read_document(&doc).unwrap();
|
||||
|
||||
let result = write_document(&doc, "two", Some(seen.modified_ms)).unwrap();
|
||||
|
||||
assert!(!result.conflict);
|
||||
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_document_writes_nothing() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
write(&doc, "# Heading\n");
|
||||
let before = fs::metadata(&doc).unwrap().modified().unwrap();
|
||||
|
||||
let read = read_document(&doc).unwrap();
|
||||
|
||||
assert_eq!(read.text, "# Heading\n");
|
||||
assert_eq!(fs::metadata(&doc).unwrap().modified().unwrap(), before);
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_that_is_not_utf8_is_an_error_and_not_a_lossy_read() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("doc.md");
|
||||
fs::write(&doc, [0xff, 0xfe, 0x00, 0x41]).unwrap();
|
||||
|
||||
assert!(read_document(&doc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_naming_does_not_collide() {
|
||||
let dir = root();
|
||||
|
||||
let first = create_file(dir.path(), "untitled.md").unwrap();
|
||||
let second = create_file(dir.path(), "untitled.md").unwrap();
|
||||
let third = create_file(dir.path(), "untitled.md").unwrap();
|
||||
|
||||
assert_eq!(first.name, "untitled.md");
|
||||
assert_eq!(second.name, "untitled-2.md");
|
||||
assert_eq!(third.name, "untitled-3.md");
|
||||
assert!(dir.path().join("untitled.md").exists());
|
||||
assert!(dir.path().join("untitled-2.md").exists());
|
||||
assert!(dir.path().join("untitled-3.md").exists());
|
||||
assert!(first.editable);
|
||||
assert_eq!(first.kind, "markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_name_never_lands_on_a_file_that_is_there() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("note.md"), "one");
|
||||
write(&dir.path().join("note-2.md"), "two");
|
||||
|
||||
assert_eq!(free_path(dir.path(), "note.md"), dir.path().join("note-3.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_folder_follows_the_same_rule() {
|
||||
let dir = root();
|
||||
|
||||
let first = create_folder(dir.path(), "untitled").unwrap();
|
||||
let second = create_folder(dir.path(), "untitled").unwrap();
|
||||
|
||||
assert_eq!(first.name, "untitled");
|
||||
assert_eq!(first.kind, "dir");
|
||||
assert!(!first.editable);
|
||||
assert_eq!(second.name, "untitled-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_skips_node_modules_and_the_rest() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("note.md"), "note");
|
||||
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
|
||||
write(&dir.path().join(".git/config"), "config");
|
||||
write(&dir.path().join("target/debug/thing"), "binary");
|
||||
write(&dir.path().join("dist/bundle.js"), "bundle");
|
||||
|
||||
let tree = scan_tree(dir.path(), false).unwrap();
|
||||
|
||||
assert_eq!(names(&tree), vec!["note.md".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_respects_a_gitignore() {
|
||||
let dir = root();
|
||||
write(&dir.path().join(".gitignore"), "drafts/\nsecret.md\n");
|
||||
write(&dir.path().join("note.md"), "note");
|
||||
write(&dir.path().join("secret.md"), "secret");
|
||||
write(&dir.path().join("drafts/half.md"), "half");
|
||||
|
||||
let tree = scan_tree(dir.path(), false).unwrap();
|
||||
|
||||
assert_eq!(names(&tree), vec!["note.md".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_ignored_files_brings_them_back() {
|
||||
let dir = root();
|
||||
write(&dir.path().join(".gitignore"), "secret.md\n");
|
||||
write(&dir.path().join("note.md"), "note");
|
||||
write(&dir.path().join("secret.md"), "secret");
|
||||
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
|
||||
|
||||
let tree = scan_tree(dir.path(), true).unwrap();
|
||||
let mut all = Vec::new();
|
||||
flat(&tree, &mut all);
|
||||
|
||||
assert!(all.contains(&"secret.md".to_string()));
|
||||
assert!(all.contains(&"node_modules".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_classifies_every_kind_and_sorts_folders_first() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("zebra.md"), "md");
|
||||
write(&dir.path().join("Apple.txt"), "txt");
|
||||
write(&dir.path().join("photo.png"), "png");
|
||||
write(&dir.path().join("beta/inner.markdown"), "md");
|
||||
|
||||
let tree = scan_tree(dir.path(), false).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
names(&tree),
|
||||
vec![
|
||||
"beta".to_string(),
|
||||
"Apple.txt".to_string(),
|
||||
"photo.png".to_string(),
|
||||
"zebra.md".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(find(&tree, "zebra.md").unwrap().kind, "markdown");
|
||||
assert!(find(&tree, "Apple.txt").unwrap().editable);
|
||||
assert_eq!(find(&tree, "Apple.txt").unwrap().kind, "text");
|
||||
assert_eq!(find(&tree, "photo.png").unwrap().kind, "other");
|
||||
assert!(!find(&tree, "photo.png").unwrap().editable);
|
||||
assert_eq!(find(&tree, "beta").unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symlink_loop_does_not_hang_the_scan() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("note.md"), "note");
|
||||
fs::create_dir(dir.path().join("inner")).unwrap();
|
||||
symlink(dir.path(), dir.path().join("inner/loop")).unwrap();
|
||||
|
||||
let tree = scan_tree(dir.path(), false).unwrap();
|
||||
let mut all = Vec::new();
|
||||
flat(&tree, &mut all);
|
||||
|
||||
assert!(all.contains(&"note.md".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_rejects_an_escape() {
|
||||
let dir = root();
|
||||
let roots = vec![dir.path().to_string_lossy().into_owned()];
|
||||
|
||||
let escape = dir.path().join("../escaped.md");
|
||||
assert!(resolve_in_roots(&roots, &escape.to_string_lossy()).is_err());
|
||||
assert!(resolve_in_roots(&roots, "/etc/hosts").is_err());
|
||||
assert!(resolve_in_roots(&roots, "notes/relative.md").is_err());
|
||||
assert!(resolve_in_roots(&[], &dir.path().join("note.md").to_string_lossy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_rejects_a_symlink_pointing_out_of_the_root() {
|
||||
let inside = root();
|
||||
let outside = root();
|
||||
let secret = outside.path().join("secret.md");
|
||||
write(&secret, "secret");
|
||||
symlink(&secret, inside.path().join("link.md")).unwrap();
|
||||
let roots = vec![inside.path().to_string_lossy().into_owned()];
|
||||
|
||||
let link = inside.path().join("link.md");
|
||||
assert!(resolve_in_roots(&roots, &link.to_string_lossy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_does_not_treat_a_sibling_as_a_child() {
|
||||
let dir = root();
|
||||
fs::create_dir(dir.path().join("notes")).unwrap();
|
||||
fs::create_dir(dir.path().join("notes-old")).unwrap();
|
||||
write(&dir.path().join("notes-old/note.md"), "note");
|
||||
let roots = vec![dir.path().join("notes").to_string_lossy().into_owned()];
|
||||
|
||||
let sibling = dir.path().join("notes-old/note.md");
|
||||
assert!(resolve_in_roots(&roots, &sibling.to_string_lossy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_accepts_what_is_really_inside() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("folder/note.md");
|
||||
write(&doc, "note");
|
||||
let roots = vec![dir.path().to_string_lossy().into_owned()];
|
||||
|
||||
let resolved = resolve_in_roots(&roots, &doc.to_string_lossy()).unwrap();
|
||||
assert_eq!(resolved, fs::canonicalize(&doc).unwrap());
|
||||
|
||||
// A file being created has no canonical path of its own, and it still has to be checked.
|
||||
let unborn = dir.path().join("folder/new.md");
|
||||
let resolved = resolve_in_roots(&roots, &unborn.to_string_lossy()).unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
fs::canonicalize(dir.path().join("folder")).unwrap().join("new.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_does_not_hard_delete() {
|
||||
let dir = root();
|
||||
let unique = format!(
|
||||
"margin-docs-trash-test-{}.md",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let doc = dir.path().join(&unique);
|
||||
write(&doc, "recoverable");
|
||||
|
||||
trash_entry(&doc).unwrap();
|
||||
|
||||
assert!(!doc.exists(), "the file is gone from where it was");
|
||||
let trashed = PathBuf::from(std::env::var("HOME").unwrap())
|
||||
.join(".Trash")
|
||||
.join(&unique);
|
||||
assert!(trashed.exists(), "and it is sitting in the Trash instead");
|
||||
|
||||
// Best effort, because the contents of the Trash are not always readable or writable by a
|
||||
// process that is not Finder. The test above is the assertion; this is only tidying up.
|
||||
let _ = fs::remove_file(&trashed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_will_not_move() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("note.md");
|
||||
write(&doc, "note");
|
||||
|
||||
assert!(rename_entry(&doc, "../elsewhere.md").is_err());
|
||||
assert!(rename_entry(&doc, "sub/other.md").is_err());
|
||||
assert!(rename_entry(&doc, " ").is_err());
|
||||
assert!(doc.exists());
|
||||
|
||||
let renamed = rename_entry(&doc, "other.md").unwrap();
|
||||
assert_eq!(renamed.name, "other.md");
|
||||
assert!(dir.path().join("other.md").exists());
|
||||
assert!(!doc.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_onto_a_name_that_is_taken_is_refused() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("one.md"), "one");
|
||||
write(&dir.path().join("two.md"), "two");
|
||||
|
||||
assert!(rename_entry(&dir.path().join("one.md"), "two.md").is_err());
|
||||
assert_eq!(fs::read_to_string(dir.path().join("two.md")).unwrap(), "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_into_the_folder_it_is_already_in_does_nothing() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("note.md");
|
||||
write(&doc, "note");
|
||||
|
||||
let node = move_entry(&doc, dir.path()).unwrap();
|
||||
|
||||
assert_eq!(node.name, "note.md");
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_will_not_put_a_folder_inside_itself() {
|
||||
let dir = root();
|
||||
let outer = dir.path().join("outer");
|
||||
let inner = outer.join("inner");
|
||||
fs::create_dir_all(&inner).unwrap();
|
||||
|
||||
assert!(move_entry(&outer, &inner).is_err());
|
||||
assert!(move_entry(&outer, &outer).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_keeps_the_name_unless_it_is_taken() {
|
||||
let dir = root();
|
||||
let from = dir.path().join("from");
|
||||
let to = dir.path().join("to");
|
||||
fs::create_dir_all(&from).unwrap();
|
||||
fs::create_dir_all(&to).unwrap();
|
||||
write(&from.join("note.md"), "moved");
|
||||
write(&to.join("note.md"), "already here");
|
||||
|
||||
let node = move_entry(&from.join("note.md"), &to).unwrap();
|
||||
|
||||
assert_eq!(node.name, "note-2.md");
|
||||
assert_eq!(fs::read_to_string(to.join("note.md")).unwrap(), "already here");
|
||||
assert_eq!(fs::read_to_string(to.join("note-2.md")).unwrap(), "moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_copies_a_folder_and_everything_under_it() {
|
||||
let dir = root();
|
||||
write(&dir.path().join("project/note.md"), "note");
|
||||
write(&dir.path().join("project/deep/inner.md"), "inner");
|
||||
|
||||
let node = duplicate_entry(&dir.path().join("project")).unwrap();
|
||||
|
||||
assert_eq!(node.name, "project-2");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("project-2/deep/inner.md")).unwrap(),
|
||||
"inner"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(dir.path().join("project/note.md")).unwrap(), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_document_is_byte_for_byte() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("note.md");
|
||||
write(&doc, "---\ntitle: x\n---\n\n# ragged heading\n");
|
||||
|
||||
let node = duplicate_entry(&doc).unwrap();
|
||||
|
||||
assert_eq!(node.name, "note-2.md");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("note-2.md")).unwrap(),
|
||||
"---\ntitle: x\n---\n\n# ragged heading\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_image_lands_in_assets_beside_the_document() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("folder/note.md");
|
||||
write(&doc, "note");
|
||||
|
||||
let first = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
|
||||
let second = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
|
||||
|
||||
assert_eq!(first.rel_path, "assets/image.png");
|
||||
assert_eq!(second.rel_path, "assets/image-2.png");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("folder/assets/image.png")).unwrap(),
|
||||
vec![0x89, 0x50, 0x4e, 0x47]
|
||||
);
|
||||
assert!(Path::new(&first.path).is_absolute());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggested_asset_name_is_a_name_and_not_a_path() {
|
||||
let dir = root();
|
||||
let doc = dir.path().join("note.md");
|
||||
write(&doc, "note");
|
||||
|
||||
let result = write_asset(&doc, &[1, 2, 3], "../../../evil.png").unwrap();
|
||||
|
||||
assert_eq!(result.rel_path, "assets/evil.png");
|
||||
assert!(dir.path().join("assets/evil.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_id_is_the_same_folder_every_time() {
|
||||
assert_eq!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/notes"));
|
||||
assert_ne!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/other"));
|
||||
assert_eq!(root_id_for("/Users/x/notes").len(), 16);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Full text search is load bearing for the index in M3, and it arrives through libsqlite3-sys's
|
||||
// bundled build rather than through a cargo feature we can see in Cargo.toml. A dependency bump
|
||||
// could drop it silently, so the assumption is asserted rather than assumed.
|
||||
|
||||
#[test]
|
||||
fn fts5_is_compiled_in() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE probe USING fts5(path, body);
|
||||
INSERT INTO probe(path, body) VALUES ('a.md', 'the quick brown fox');
|
||||
INSERT INTO probe(path, body) VALUES ('b.md', 'lazy dog sleeping');",
|
||||
)
|
||||
.expect("fts5 virtual table must be creatable");
|
||||
let hit: String = conn
|
||||
.query_row("SELECT path FROM probe WHERE probe MATCH 'brown'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(hit, "a.md");
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// The watcher against a real folder and real filesystem calls, because every interesting thing it
|
||||
// does is a reaction to what the kernel actually reports rather than to what notify's documentation
|
||||
// says it reports. FSEvents sets `ItemCreated` on every event it ever emits for a path, describes
|
||||
// an atomic save as a rename with no relation to the file it replaced, and spells every path
|
||||
// through /private. None of that is visible from the types.
|
||||
//
|
||||
// Waiting is done by writing a probe file and waiting for its event, not by sleeping. Batches are
|
||||
// delivered oldest first, so the probe's event arriving is proof that everything caused before it
|
||||
// has already been delivered, which is what makes "nothing was reported" a bounded assertion rather
|
||||
// than a guess at how long to wait. The one deliberate sleep is in the fixture helper, where a file
|
||||
// has to be older than the watcher's own idea of newly born for the test to mean anything.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use margin_docs_lib::dto::WatchEvent;
|
||||
use margin_docs_lib::fs::write_document;
|
||||
use margin_docs_lib::watch::{note_self_write, spawn_watcher};
|
||||
use notify::RecommendedWatcher;
|
||||
use notify_debouncer_full::{Debouncer, NoCache};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// How long a test waits for the watcher to prove it is running before calling it broken.
|
||||
const DEADLINE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The debouncer holds an event for 300ms and ticks every quarter of that, so a batch that has not
|
||||
/// arrived in this long is not on its way.
|
||||
const QUIET: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Comfortably more than the 250ms within which the watcher counts a file as newly born, so that a
|
||||
/// fixture written by the test is unambiguously a file that was already there.
|
||||
const AGE: Duration = Duration::from_millis(600);
|
||||
|
||||
const ROOT: &str = "root-1";
|
||||
|
||||
struct Harness {
|
||||
/// Declared first so the watcher stops before the folder it is watching is deleted.
|
||||
_watcher: Debouncer<RecommendedWatcher, NoCache>,
|
||||
dir: TempDir,
|
||||
rx: Receiver<WatchEvent>,
|
||||
probes: AtomicU32,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.path().join(name)
|
||||
}
|
||||
|
||||
/// Waits until the watcher is up and throws away whatever it has reported so far.
|
||||
fn sync(&self) {
|
||||
self.drain();
|
||||
}
|
||||
|
||||
/// Everything reported up to a fresh probe file, the probe events themselves left out.
|
||||
fn drain(&self) -> Vec<WatchEvent> {
|
||||
let give_up = Instant::now() + DEADLINE;
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let n = self.probes.fetch_add(1, Ordering::Relaxed);
|
||||
let probe = self.path(&format!("probe-{n}.md"));
|
||||
fs::write(&probe, format!("probe {n}\n")).unwrap();
|
||||
let want = probe.to_string_lossy().into_owned();
|
||||
loop {
|
||||
match self.rx.recv_timeout(QUIET) {
|
||||
Ok(event) if event.path == want => return seen,
|
||||
Ok(event) => {
|
||||
if !is_probe(&event.path) {
|
||||
seen.push(event);
|
||||
}
|
||||
}
|
||||
// Nothing is arriving at all, so the watcher was not yet up when the probe was
|
||||
// written. Write another one.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < give_up,
|
||||
"the watcher never reported anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn events_for(&self, path: &Path) -> Vec<WatchEvent> {
|
||||
let want = path.to_string_lossy().into_owned();
|
||||
self.drain()
|
||||
.into_iter()
|
||||
.filter(|event| event.path == want)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A watched folder holding `fixtures`, all of them old enough to count as files that were already
|
||||
/// there when the watch started.
|
||||
fn harness(fixtures: &[&str]) -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in fixtures {
|
||||
let path = dir.path().join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, format!("# {name}\n")).unwrap();
|
||||
}
|
||||
if !fixtures.is_empty() {
|
||||
std::thread::sleep(AGE);
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let watcher = spawn_watcher(
|
||||
ROOT.to_string(),
|
||||
dir.path().to_string_lossy().into_owned(),
|
||||
move |events| {
|
||||
for event in events {
|
||||
tx.send(event).ok();
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
Harness {
|
||||
_watcher: watcher,
|
||||
dir,
|
||||
rx,
|
||||
probes: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_probe(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("probe-"))
|
||||
}
|
||||
|
||||
fn one<'a>(events: &'a [WatchEvent], what: &str) -> &'a WatchEvent {
|
||||
assert_eq!(events.len(), 1, "{what}, got {events:?}");
|
||||
&events[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_created_file_is_reported_created() {
|
||||
let harness = harness(&[]);
|
||||
harness.sync();
|
||||
|
||||
let note = harness.path("note.md");
|
||||
fs::write(¬e, "# Note\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
let event = one(&events, "a new file is one event");
|
||||
assert_eq!(event.kind, "created");
|
||||
assert_eq!(event.root, ROOT);
|
||||
assert_eq!(event.old_path, None);
|
||||
assert_eq!(event.path, note.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modified_file_is_reported_modified() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
fs::write(¬e, "# Note\n\nA second paragraph.\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
let event = one(&events, "a write to an existing file is one event");
|
||||
assert_eq!(event.kind, "modified");
|
||||
assert_eq!(event.old_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_file_is_reported_removed() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
fs::remove_file(¬e).unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
assert_eq!(one(&events, "a delete is one event").kind, "removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_write_is_reported_as_nothing() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
note_self_write(¬e);
|
||||
fs::write(¬e, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
assert!(events.is_empty(), "the app's own write echoed: {events:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apps_own_atomic_save_is_reported_as_nothing() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
let temp = harness.path("note.md.tmp");
|
||||
harness.sync();
|
||||
|
||||
// Both halves have to be registered. The rename names the temp file as well as the document,
|
||||
// and either name getting through would let the echo through with it.
|
||||
note_self_write(&temp);
|
||||
note_self_write(¬e);
|
||||
fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||
fs::rename(&temp, ¬e).unwrap();
|
||||
|
||||
let events = harness.drain();
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"the app's own atomic save echoed: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two tests above register the paths by hand, which proves the mechanism and not that anything
|
||||
/// uses it. This one goes through the real write path: a save of a document reaches the frontend as
|
||||
/// nothing at all, neither the document nor the temp file it went through.
|
||||
#[test]
|
||||
fn a_real_save_is_reported_as_nothing() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
write_document(¬e, "# Note\n\nSaved by the app itself.\n", None).unwrap();
|
||||
|
||||
let events = harness.drain();
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"the app's own save came back as an external change: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_write_stops_suppressing_once_the_window_is_up() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
note_self_write(¬e);
|
||||
fs::write(¬e, "one\n").unwrap();
|
||||
assert!(harness.events_for(¬e).is_empty());
|
||||
|
||||
// The suppression is a window and not a switch: a later write to the same path, by the app or
|
||||
// by anything else, has to come through again once the window is up.
|
||||
std::thread::sleep(Duration::from_millis(2_100));
|
||||
fs::write(¬e, "two\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
assert_eq!(
|
||||
one(&events, "the write after the window is one event").kind,
|
||||
"modified"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_writes_is_not_a_burst_of_events() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
for n in 0..10 {
|
||||
fs::write(¬e, format!("# Note\n\nRevision {n}.\n")).unwrap();
|
||||
}
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
assert!(
|
||||
(1..=2).contains(&events.len()),
|
||||
"ten writes should coalesce, got {events:?}"
|
||||
);
|
||||
assert!(events.iter().all(|event| event.kind == "modified"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_is_reported_at_both_ends() {
|
||||
let harness = harness(&["before.md"]);
|
||||
let before = harness.path("before.md");
|
||||
let after = harness.path("after.md");
|
||||
harness.sync();
|
||||
|
||||
fs::rename(&before, &after).unwrap();
|
||||
|
||||
let events = harness.drain();
|
||||
let gone = events
|
||||
.iter()
|
||||
.find(|event| event.path == before.to_string_lossy())
|
||||
.unwrap_or_else(|| panic!("the old name was not reported: {events:?}"));
|
||||
assert_eq!(gone.kind, "removed");
|
||||
|
||||
// Not `renamed`. FSEvents reports the two ends as unrelated events and marks both of them
|
||||
// created, which defeats the debouncer's attempt to pair them up, so the honest report is that
|
||||
// one name went away and another appeared.
|
||||
let arrived = events
|
||||
.iter()
|
||||
.find(|event| event.path == after.to_string_lossy())
|
||||
.unwrap_or_else(|| panic!("the new name was not reported: {events:?}"));
|
||||
assert_ne!(arrived.kind, "removed");
|
||||
if let Some(old) = &arrived.old_path {
|
||||
assert_eq!(old, &*before.to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_editors_atomic_save_is_one_event_on_the_document() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
let temp = harness.path(".note.md.tmp");
|
||||
harness.sync();
|
||||
|
||||
fs::write(&temp, "# Note\n\nSaved by something else.\n").unwrap();
|
||||
fs::rename(&temp, ¬e).unwrap();
|
||||
|
||||
let events = harness.drain();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|event| event.path != temp.to_string_lossy()),
|
||||
"the temp file was reported as if it were a document: {events:?}"
|
||||
);
|
||||
let on_note: Vec<_> = events
|
||||
.into_iter()
|
||||
.filter(|event| event.path == note.to_string_lossy())
|
||||
.collect();
|
||||
let event = one(&on_note, "an atomic save is one event on the document");
|
||||
assert_ne!(event.kind, "removed");
|
||||
assert_eq!(
|
||||
event.old_path, None,
|
||||
"the document was reported as renamed from a temp file it never was"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_paths_are_not_reported() {
|
||||
let harness = harness(&[]);
|
||||
harness.sync();
|
||||
|
||||
fs::create_dir_all(harness.path(".git")).unwrap();
|
||||
fs::write(harness.path(".git/index"), "not a document").unwrap();
|
||||
fs::write(harness.path(".DS_Store"), "not a document either").unwrap();
|
||||
let note = harness.path("note.md");
|
||||
fs::write(¬e, "# Note\n").unwrap();
|
||||
|
||||
let events = harness.drain();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|event| event.path == note.to_string_lossy()),
|
||||
"hidden paths reached the frontend: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_changes_are_reported_under_the_path_the_root_was_opened_as() {
|
||||
let harness = harness(&["sub/deep.md"]);
|
||||
let nested = harness.path("sub/deep.md");
|
||||
harness.sync();
|
||||
|
||||
fs::write(&nested, "# Deep\n\nEdited.\n").unwrap();
|
||||
|
||||
let events = harness.events_for(&nested);
|
||||
let event = one(&events, "a nested file is one event");
|
||||
assert_eq!(event.kind, "modified");
|
||||
// Not the /private form FSEvents hands out, which nothing else in the app spells that way.
|
||||
assert!(event
|
||||
.path
|
||||
.starts_with(&*harness.dir.path().to_string_lossy()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_root_is_reported_removed() {
|
||||
let outer = tempfile::tempdir().unwrap();
|
||||
let root = outer.path().join("notes");
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::write(root.join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let watcher = spawn_watcher(
|
||||
ROOT.to_string(),
|
||||
root.to_string_lossy().into_owned(),
|
||||
move |events| {
|
||||
for event in events {
|
||||
tx.send(event).ok();
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let give_up = Instant::now() + DEADLINE;
|
||||
let mut live = false;
|
||||
let mut probe = 0;
|
||||
while !live {
|
||||
fs::write(root.join(format!("probe-{probe}.md")), "probe\n").unwrap();
|
||||
probe += 1;
|
||||
live = rx.recv_timeout(QUIET).is_ok();
|
||||
assert!(
|
||||
live || Instant::now() < give_up,
|
||||
"the watcher never reported anything"
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let want = root.to_string_lossy().into_owned();
|
||||
let give_up = Instant::now() + DEADLINE;
|
||||
let mut removed = false;
|
||||
while !removed && Instant::now() < give_up {
|
||||
match rx.recv_timeout(QUIET) {
|
||||
Ok(event) => removed = event.path == want && event.kind == "removed",
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(removed, "deleting the root reported nothing");
|
||||
|
||||
drop(watcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_on_a_folder_that_is_not_there_is_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("gone");
|
||||
let started = spawn_watcher(
|
||||
ROOT.to_string(),
|
||||
missing.to_string_lossy().into_owned(),
|
||||
|_| {},
|
||||
);
|
||||
assert!(started.is_err());
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// The shape of a `watch-event` as the frontend receives it, rather than as Rust holds it.
|
||||
//
|
||||
// src-tauri/tests/watch.rs proves the watcher reports the right things about the right paths, but
|
||||
// it asserts against `WatchEvent`'s Rust fields, and the frontend never sees those. What crosses
|
||||
// the IPC boundary is serde's JSON, and the frontend reads `event.payload.oldPath` off it. A
|
||||
// missing `#[serde(rename_all = "camelCase")]` would leave every Rust test green and hand the
|
||||
// frontend `old_path`, which reads as `undefined`, which is neither the string nor the null the
|
||||
// TypeScript type promises. Nothing else in the suite would notice.
|
||||
//
|
||||
// So this file drives the same real watcher over a real folder and asserts the serialized object
|
||||
// exactly: every key, no extra keys, and the literal values the TypeScript union in src/ipc.ts
|
||||
// lists. Between this and the Playwright suite in tests/external-changes.spec.ts, which feeds
|
||||
// payloads of this shape through the real Tauri listener into the real UI, both ends of the wire
|
||||
// are pinned to the same object.
|
||||
//
|
||||
// The waiting strategy is the one watch.rs uses and for the same reason: a probe file whose event
|
||||
// proves everything caused before it has already been delivered.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use margin_docs_lib::dto::WatchEvent;
|
||||
use margin_docs_lib::watch::spawn_watcher;
|
||||
use notify::RecommendedWatcher;
|
||||
use notify_debouncer_full::{Debouncer, NoCache};
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const DEADLINE: Duration = Duration::from_secs(15);
|
||||
const QUIET: Duration = Duration::from_millis(900);
|
||||
const AGE: Duration = Duration::from_millis(600);
|
||||
const ROOT: &str = "root-1";
|
||||
|
||||
struct Harness {
|
||||
_watcher: Debouncer<RecommendedWatcher, NoCache>,
|
||||
dir: TempDir,
|
||||
rx: Receiver<WatchEvent>,
|
||||
probes: AtomicU32,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.path().join(name)
|
||||
}
|
||||
|
||||
fn sync(&self) {
|
||||
self.drain();
|
||||
}
|
||||
|
||||
fn drain(&self) -> Vec<WatchEvent> {
|
||||
let give_up = Instant::now() + DEADLINE;
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let n = self.probes.fetch_add(1, Ordering::Relaxed);
|
||||
let probe = self.path(&format!("probe-{n}.md"));
|
||||
fs::write(&probe, format!("probe {n}\n")).unwrap();
|
||||
let want = probe.to_string_lossy().into_owned();
|
||||
loop {
|
||||
match self.rx.recv_timeout(QUIET) {
|
||||
Ok(event) if event.path == want => return seen,
|
||||
Ok(event) => {
|
||||
if !is_probe(&event.path) {
|
||||
seen.push(event);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < give_up,
|
||||
"the watcher never reported anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The one event for `path`, as the JSON object the frontend will be handed.
|
||||
fn payload_for(&self, path: &Path) -> Value {
|
||||
let want = path.to_string_lossy().into_owned();
|
||||
let events: Vec<WatchEvent> = self
|
||||
.drain()
|
||||
.into_iter()
|
||||
.filter(|event| event.path == want)
|
||||
.collect();
|
||||
assert_eq!(events.len(), 1, "expected one event, got {events:?}");
|
||||
serde_json::to_value(&events[0]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(fixtures: &[&str]) -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in fixtures {
|
||||
let path = dir.path().join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, format!("# {name}\n")).unwrap();
|
||||
}
|
||||
if !fixtures.is_empty() {
|
||||
std::thread::sleep(AGE);
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let watcher = spawn_watcher(
|
||||
ROOT.to_string(),
|
||||
dir.path().to_string_lossy().into_owned(),
|
||||
move |events| {
|
||||
for event in events {
|
||||
tx.send(event).ok();
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
Harness {
|
||||
_watcher: watcher,
|
||||
dir,
|
||||
rx,
|
||||
probes: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_probe(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("probe-"))
|
||||
}
|
||||
|
||||
/// Exactly these four keys, spelled the way `WatchEvent` in src/ipc.ts spells them.
|
||||
fn expect_payload(actual: &Value, path: &Path, kind: &str) {
|
||||
assert_eq!(
|
||||
actual,
|
||||
&json!({
|
||||
"root": ROOT,
|
||||
"path": path.to_string_lossy(),
|
||||
"kind": kind,
|
||||
"oldPath": Value::Null,
|
||||
}),
|
||||
"the payload the frontend receives is not the object it is typed as"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_external_edit_serialises_as_the_frontend_reads_it() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
fs::write(¬e, "# Note\n\nEdited by another program.\n").unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "modified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_created_file_serialises_as_the_frontend_reads_it() {
|
||||
let harness = harness(&[]);
|
||||
harness.sync();
|
||||
|
||||
let note = harness.path("note.md");
|
||||
fs::write(¬e, "# Note\n").unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_file_serialises_as_the_frontend_reads_it() {
|
||||
let harness = harness(&["note.md"]);
|
||||
let note = harness.path("note.md");
|
||||
harness.sync();
|
||||
|
||||
fs::remove_file(¬e).unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "removed");
|
||||
}
|
||||
|
||||
/// `old_path` is the one field whose name differs between the two languages, and the only one that
|
||||
/// is ever anything but a plain string. A rename is where it would be filled in if it ever were,
|
||||
/// so this is where a wrong spelling would do its damage.
|
||||
#[test]
|
||||
fn old_path_is_spelled_the_way_the_frontend_reads_it() {
|
||||
let renamed = WatchEvent {
|
||||
root: ROOT.to_string(),
|
||||
path: "/tmp/after.md".to_string(),
|
||||
kind: "renamed".to_string(),
|
||||
old_path: Some("/tmp/before.md".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&renamed).unwrap(),
|
||||
json!({
|
||||
"root": ROOT,
|
||||
"path": "/tmp/after.md",
|
||||
"kind": "renamed",
|
||||
"oldPath": "/tmp/before.md",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A source-literal check and nothing more: it cannot see a running app. What it does catch is the
|
||||
/// one silent break the runtime tests on either side cannot, because each side is internally
|
||||
/// consistent with its own constant. Rename the event on one side and the frontend simply stops
|
||||
/// hearing anything, with every test still green.
|
||||
#[test]
|
||||
fn both_sides_name_the_event_the_same_string() {
|
||||
assert!(
|
||||
include_str!("../src/watch.rs").contains(r#"const WATCH_EVENT: &str = "watch-event";"#),
|
||||
"the backend no longer emits under `watch-event`"
|
||||
);
|
||||
assert!(
|
||||
include_str!("../../src/ipc.ts").contains(r#"export const WATCH_EVENT = "watch-event";"#),
|
||||
"the frontend no longer listens for `watch-event`"
|
||||
);
|
||||
}
|
||||