feat: Source-mode heading folding, graph timelapse, workspaces, heading and block links
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Source view: chevrons fold the section under a heading (laid out at a vanishing size, one buffer).
Graph: timelapse by file creation time with play/pause and a slider. Workspaces: save and load the tabs,
active tab, pins, panel sizes and sidebar tab by name (engine workspace.* commands, files in
.notescraft/workspaces). Copy a heading or block link (adds a ^id once), Outline context menu.
Round 4 tasks 31-35 added to specs/obsidian-basics/tasks.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-19 15:22:19 -03:00
parent bb7f8ae79d
commit 6ecbeadcff
46 changed files with 1125 additions and 41 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ Pending, by area:
- **Code blocks, grammars and formatting (done, 15 of 15 tasks, specs/code-blocks-and-formatting):** `toml` and `ts` fences now highlight through two original grammars embedded in `notescraft-syntax` (`crates/syntax/grammars/*.sublime-syntax`), and the batch review has a third check, "Add missing language labels": `blocks::detect_language` names the language of an unlabeled fence conservatively (shebang, PHP, HTML, XML, JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML, on the first 4 KiB and 40 non-blank lines) and each `ChangeKind::Label` is a zero-length insert right after the fence run that always needs review (`batch.start` takes `labels`, on by default). Not built — the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. A note with 50 code blocks draws its first frame in 15 ms median (release), 87 ms in a cold process.
- **Editor previews (done, 11 of 11 tasks, specs/editor-previews):** not built or limited — no swatches or arrows in Source mode or in rows carrying inline math, and a hex that Obsidian reads as a tag (`#cba6f7` in prose) stays a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only if the diagram parser returns an unrenderable kind (today it answers `NoHeader` for one it does not know, so those show the Error chip). Scanning 100 rows of 10 colours (1,000 hits) takes 0.05 ms median, 0.15 ms worst, in release.
- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), audio embeds with Play/Stop through the system player, video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers and user themes (30) built; unbuilt: hand check of the packaged app and a Windows playback run.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers, user themes, the graph timelapse and Source-mode heading folding (30), workspaces (32) and heading/block links (33) built; unbuilt: hand check of the packaged app and a Windows playback run.
- **Linux and Windows feedback (2026-10-19):** built: the Explorer calendar folds (chevron, remembered, folded on a first run in a window under 720 pt), the Explorer toolbar is laid out by hand (filter box plus two pill buttons, fixed gaps, flush right), the empty Bookmarks section is gone, the menu bar words are flat text with a soft hover/open background, the Related tab scrolls, a new Outline pane (count, filter, folding, active heading, scroll), a performance overlay (`view.performance`: fps, frame ms, memory, CPU; `/proc`, `ps` or PowerShell, no new dependency), and a first-run UI scale taken from the desktop on Linux when the window was not told its scale (`QT_SCALE_FACTOR`, `GDK_SCALE`, `Xft.dpi`, GNOME text scaling). Not confirmed on those systems: the title bar blink (the bar is now drawn whole on every frame and sits above the cards) and Explorer icons drawn out of bounds on a fast scroll (not reproduced offscreen; rows are now clipped to the viewport). Windows paths (PowerShell sampler, menu bar) were not run.
- **Editor (built, see phase 14):** spell-check needs an owner decision (new dependency or in-house checker); open: mobile bottom toolbar, inline fix popover, block menu by keyboard, embed with other text on one line, checkboxes inside embeds, embed refresh after editing another tab.
- **Editor settings:** the page has only Vim mode; more settings need editor features first (line numbers, spellcheck, font size, ...), then sub-menus for them.
+106
View File
@@ -0,0 +1,106 @@
//! Block ids (`^abc123` at the end of a line), so a block can be linked as `[[Note#^abc123]]`.
use super::{BlockKind, parse_blocks};
/// Length of a generated id.
const ID_LEN: usize = 6;
/// Characters of a generated id (what Obsidian accepts: letters, digits and dashes).
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
/// The id a line ends with (`text ^abc-1`), without the caret.
#[must_use]
pub fn block_id_in(line: &str) -> Option<&str> {
let (_, tail) = line.trim_end().rsplit_once(" ^")?;
(!tail.is_empty() && tail.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')).then_some(tail)
}
/// An id made from `salt` and the line it names; the same input gives the same id.
fn generate(salt: u64, line: &str) -> String {
let mut hash = salt ^ 0xcbf2_9ce4_8422_2325;
for byte in line.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
(0..ID_LEN)
.map(|_| {
let pick = usize::try_from(hash % u64::try_from(ALPHABET.len()).unwrap_or(1)).unwrap_or(0);
hash = hash.rotate_left(11).wrapping_mul(0x9e37_79b9_7f4a_7c15).wrapping_add(1);
char::from(ALPHABET.get(pick).copied().unwrap_or(b'a'))
})
.collect()
}
/// The id of the block at `offset`: the one its line already ends with, or a new one and the insertion that adds
/// it (`(byte offset, " ^id")`). Paragraphs, quotes, callouts, tasks and list items can carry an id (a list item on
/// its own line); other blocks answer `None`. `salt` makes the new id differ between notes.
#[must_use]
pub fn ensure_block_id(text: &str, offset: usize, salt: u64) -> Option<(String, Option<(usize, String)>)> {
let block = parse_blocks(text).into_iter().find(|block| block.range.start <= offset && offset <= block.range.end)?;
if !matches!(block.kind, BlockKind::Paragraph | BlockKind::Quote | BlockKind::Callout | BlockKind::TaskItem { .. } | BlockKind::ListItem { .. }) {
return None;
}
// A list item carries its id on the line the caret is on; the others on their last line.
let line_end = if matches!(block.kind, BlockKind::TaskItem { .. } | BlockKind::ListItem { .. }) {
let from = offset.min(block.range.end);
let tail = text.get(from..block.range.end)?;
from + tail.find('\n').unwrap_or(tail.len())
} else {
block.range.end
};
let line_start = text.get(..line_end)?.rfind('\n').map_or(0, |at| at + 1);
let line = text.get(line_start..line_end)?.trim_end_matches('\r');
if let Some(existing) = block_id_in(line) {
return Some((existing.to_owned(), None));
}
let id = generate(salt, line);
Some((id.clone(), Some((line_start + line.len(), format!(" ^{id}")))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_existing_id_is_found_at_the_end_of_a_line_only() {
assert_eq!(block_id_in("text ^abc-1"), Some("abc-1"));
assert_eq!(block_id_in("text ^abc-1 "), Some("abc-1"));
assert_eq!(block_id_in("a^b"), None);
assert_eq!(block_id_in("text ^"), None);
assert_eq!(block_id_in("text ^a b"), None);
assert_eq!(block_id_in("^only"), None, "an id follows text");
}
#[test]
fn a_paragraph_gets_one_id_once_and_reuses_it() {
let text = "intro\n\nthe point of the note\ncontinues here\n\nend\n";
let at = text.find("point").expect("offset");
let (id, insert) = ensure_block_id(text, at, 7).expect("a paragraph can carry an id");
assert_eq!(id.len(), ID_LEN);
let (position, added) = insert.expect("a new id is inserted");
assert_eq!(added, format!(" ^{id}"));
assert_eq!(text.get(..position), Some("intro\n\nthe point of the note\ncontinues here"), "on the last line of the block");
let mut next = text.to_owned();
next.insert_str(position, &added);
assert_eq!(ensure_block_id(&next, at, 7), Some((id, None)), "the second time nothing is added");
}
#[test]
fn a_list_item_carries_the_id_on_its_own_line_and_other_blocks_refuse() {
let text = "- one\n- two\n- three\n\n```\ncode\n```\n";
let at = text.find("two").expect("offset");
let (_, insert) = ensure_block_id(text, at, 1).expect("list item");
let (position, _) = insert.expect("insertion");
assert_eq!(text.get(..position), Some("- one\n- two"));
assert_eq!(ensure_block_id(text, text.find("code").expect("offset"), 1), None, "code cannot carry an id");
assert_eq!(ensure_block_id("", 0, 1), None);
assert_eq!(ensure_block_id("text", 99, 1), None, "an offset outside the text names no block");
}
#[test]
fn ids_are_valid_and_differ_by_salt() {
let one = generate(1, "same line");
assert!(one.bytes().all(|b| ALPHABET.contains(&b)));
assert_ne!(one, generate(2, "same line"));
assert_eq!(one, generate(1, "same line"));
}
}
+2
View File
@@ -9,6 +9,7 @@
mod convert;
mod detect;
mod find;
mod ids;
mod insert;
mod json;
mod ops;
@@ -19,6 +20,7 @@ use std::ops::Range;
pub use convert::{Target, turn_into};
pub use detect::detect_language;
pub use find::{FindOptions, RegexFind, find_all, find_regex, replace_ranges};
pub use ids::{block_id_in, ensure_block_id};
pub use insert::{Component, insert_component};
pub use json::{MAX_JSON_BYTES, check_json, format_json, format_json_block, json_span, looks_like_json, match_line_endings, minify_json, minify_json_block};
pub use ops::{Direction, delete_block, duplicate_block, move_block};
+7 -1
View File
@@ -22,6 +22,10 @@ pub struct EditorState {
/// A pending caret jump (byte offset): the next frame moves the caret
/// there and scrolls it into view, then clears itself.
pub jump: Option<usize>,
/// The heading lines (`## Title`) whose sections are folded away in the Source view; a heading that is edited
/// or removed simply stops folding.
#[serde(default)]
pub folded: std::collections::BTreeSet<String>,
}
/// Widget options.
@@ -157,8 +161,10 @@ pub fn show_modal_editor(
state.cursor = Some(sel.0);
}
let folded = state.folded.clone();
let mut layouter = |layout_ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
let job = crate::highlight::layout_job(buf.as_str(), colors, opts.font_size, wrap_width, resolver);
let hidden = crate::fold::hidden_ranges(buf.as_str(), &folded);
let job = crate::highlight::layout_job_folded(buf.as_str(), colors, opts.font_size, wrap_width, resolver, &hidden);
layout_ui.ctx().fonts_mut(|f| f.layout_job(job))
};
let cursor_style = ui.visuals().text_cursor.clone();
+123
View File
@@ -0,0 +1,123 @@
//! Folding headings in the Source view: the lines under a folded heading are laid out at a vanishing size, so
//! the text stays one buffer (carets, selections and edits keep working) while the section disappears.
use std::collections::BTreeSet;
use std::ops::Range;
/// A heading line of the source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heading {
/// Byte offset of the start of the heading line.
pub start: usize,
/// Byte offset of the end of the heading line (before its newline).
pub line_end: usize,
/// `1..=6`.
pub level: u8,
/// The heading line without surrounding whitespace: what identifies the fold.
pub key: String,
}
/// The ATX headings of `text` outside fenced code blocks, in order.
#[must_use]
pub fn headings(text: &str) -> Vec<Heading> {
let mut out = Vec::new();
let mut fence: Option<(char, usize)> = None;
let mut start = 0;
for line in text.split_inclusive('\n') {
let end = start + line.trim_end_matches(['\n', '\r']).len();
let trimmed = line.trim_start();
let marker = trimmed.chars().next().filter(|c| matches!(c, '`' | '~'));
let run = marker.map_or(0, |m| trimmed.chars().take_while(|c| *c == m).count());
match (fence, marker) {
(Some((open, count)), Some(m)) if m == open && run >= count && trimmed[run..].trim().is_empty() => fence = None,
(Some(_), _) => {}
(None, Some(m)) if run >= 3 => fence = Some((m, run)),
(None, _) => {
let hashes = line.bytes().take_while(|b| *b == b'#').count();
if (1..=6).contains(&hashes) && line.as_bytes().get(hashes).is_some_and(|b| matches!(b, b' ' | b'\t')) {
out.push(Heading { start, line_end: end, level: u8::try_from(hashes).unwrap_or(6), key: line.trim().to_owned() });
}
}
}
start += line.len();
}
out
}
/// The body of the heading at `index` of `headings` in a text `len` bytes long: from the line after the heading up
/// to the next heading of the same or a higher level (or the end). `None` when the body is empty.
#[must_use]
pub fn body_of(headings: &[Heading], index: usize, text: &str) -> Option<Range<usize>> {
let heading = headings.get(index)?;
let from = (heading.line_end + 1).min(text.len());
let to = headings.iter().skip(index + 1).find(|next| next.level <= heading.level).map_or(text.len(), |next| next.start);
(from < to).then_some(from..to)
}
/// The byte ranges hidden by the folded headings (those whose line is in `folded`), sorted and without overlap.
#[must_use]
pub fn hidden_ranges(text: &str, folded: &BTreeSet<String>) -> Vec<Range<usize>> {
if folded.is_empty() {
return Vec::new();
}
let found = headings(text);
let mut out: Vec<Range<usize>> = Vec::new();
for (index, heading) in found.iter().enumerate() {
if !folded.contains(&heading.key) {
continue;
}
let Some(range) = body_of(&found, index, text) else { continue };
match out.last_mut() {
Some(last) if range.start <= last.end => last.end = last.end.max(range.end),
_ => out.push(range),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: &str = "# One\nintro\n## Two\nbody two\n### Three\nbody three\n## Four\nbody four\n```\n# not a heading\n```\n";
#[test]
fn headings_skip_code_fences_and_non_headings() {
let found = headings(TEXT);
let keys: Vec<&str> = found.iter().map(|heading| heading.key.as_str()).collect();
assert_eq!(keys, ["# One", "## Two", "### Three", "## Four"]);
assert_eq!(found.first().map(|heading| (heading.start, heading.line_end)), Some((0, 5)));
assert_eq!(headings("#no space\n####### seven\n").len(), 0);
}
#[test]
fn a_section_runs_to_the_next_heading_of_the_same_or_a_higher_level() {
let found = headings(TEXT);
let slice = |index: usize| body_of(&found, index, TEXT).and_then(|range| TEXT.get(range));
assert_eq!(slice(1), Some("body two\n### Three\nbody three\n"), "a sub-heading is inside its parent");
assert_eq!(slice(2), Some("body three\n"));
assert!(slice(0).is_some_and(|body| body.ends_with("```\n")), "the title owns everything");
assert_eq!(body_of(&headings("# a\n## b\n"), 1, "# a\n## b\n"), None, "an empty body hides nothing");
}
#[test]
fn hidden_ranges_follow_the_folded_headings_and_merge() {
let folded: BTreeSet<String> = ["## Two".to_owned()].into();
let hidden = hidden_ranges(TEXT, &folded);
assert_eq!(hidden.len(), 1);
assert_eq!(hidden.first().and_then(|range| TEXT.get(range.clone())), Some("body two\n### Three\nbody three\n"));
let both: BTreeSet<String> = ["## Two".to_owned(), "### Three".to_owned()].into();
assert_eq!(hidden_ranges(TEXT, &both).len(), 1, "a fold inside a fold merges");
assert_eq!(hidden_ranges(TEXT, &BTreeSet::new()).len(), 0);
let stale: BTreeSet<String> = ["## Gone".to_owned()].into();
assert_eq!(hidden_ranges(TEXT, &stale).len(), 0, "a heading that was edited away folds nothing");
}
#[test]
fn hostile_text_does_not_panic() {
for text in ["", "#", "# \n", "\u{feff}# x\n", "```\n# x", &"#".repeat(10_000), &"# a\n".repeat(5_000)] {
let folded: BTreeSet<String> = [text.lines().next().unwrap_or("").to_owned()].into();
let _ = hidden_ranges(text, &folded);
}
}
}
+41 -5
View File
@@ -89,6 +89,22 @@ fn fmt(font: FontId, color: Color32, bg: Color32) -> TextFormat {
/// the vault (resolved and unresolved links are coloured differently).
#[must_use]
pub fn layout_job(text: &str, colors: &EditorColors, font_size: f32, wrap_width: f32, resolver: &dyn Fn(&str) -> bool) -> egui::text::LayoutJob {
layout_job_folded(text, colors, font_size, wrap_width, resolver, &[])
}
/// Size of text in a folded section, in points: small enough that its lines take no room.
const HIDDEN_SIZE: f32 = 0.1;
/// [`layout_job`] with the byte ranges `hidden` (sorted, without overlap) laid out at a vanishing size and no colour.
#[must_use]
pub fn layout_job_folded(
text: &str,
colors: &EditorColors,
font_size: f32,
wrap_width: f32,
resolver: &dyn Fn(&str) -> bool,
hidden: &[std::ops::Range<usize>],
) -> egui::text::LayoutJob {
let mut job = egui::text::LayoutJob::default();
job.wrap.max_width = if wrap_width.is_finite() { wrap_width } else { f32::INFINITY };
job.break_on_newline = true;
@@ -100,21 +116,41 @@ pub fn layout_job(text: &str, colors: &EditorColors, font_size: f32, wrap_width:
let mut pos = 0usize;
for a in kept {
if a.range.start > pos {
push_chunk(&mut job, text, pos..a.range.start, Sty::Plain, colors, font_size);
push_chunk(&mut job, text, pos..a.range.start, (Sty::Plain, hidden), colors, font_size);
}
if a.range.end > a.range.start {
push_chunk(&mut job, text, a.range.clone(), a.sty, colors, font_size);
push_chunk(&mut job, text, a.range.clone(), (a.sty, hidden), colors, font_size);
pos = a.range.end;
}
}
if pos < text.len() {
push_chunk(&mut job, text, pos..text.len(), Sty::Plain, colors, font_size);
push_chunk(&mut job, text, pos..text.len(), (Sty::Plain, hidden), colors, font_size);
}
job
}
fn push_chunk(job: &mut egui::text::LayoutJob, text: &str, range: std::ops::Range<usize>, sty: Sty, colors: &EditorColors, base: f32) {
if let Some(chunk) = text.get(range.clone()) {
fn push_chunk(
job: &mut egui::text::LayoutJob,
text: &str,
range: std::ops::Range<usize>,
style: (Sty, &[std::ops::Range<usize>]),
colors: &EditorColors,
base: f32,
) {
let (sty, hidden) = style;
let mut at = range.start;
// The part of the chunk inside a hidden range gets the vanishing format; the rest keeps its style.
for gap in hidden.iter().filter(|gap| gap.end > range.start && gap.start < range.end) {
let (from, to) = (gap.start.max(range.start), gap.end.min(range.end));
if let Some(visible) = text.get(at..from).filter(|part| !part.is_empty()) {
job.append(visible, 0.0, text_format(sty, colors, base));
}
if let Some(folded) = text.get(from..to).filter(|part| !part.is_empty()) {
job.append(folded, 0.0, fmt(FontId::proportional(HIDDEN_SIZE), Color32::TRANSPARENT, Color32::TRANSPARENT));
}
at = to;
}
if let Some(chunk) = text.get(at..range.end).filter(|part| !part.is_empty()) {
job.append(chunk, 0.0, text_format(sty, colors, base));
}
}
+1
View File
@@ -16,6 +16,7 @@ pub use notescraft_blocks as blocks;
pub mod colors;
pub mod completion;
pub mod editor;
pub mod fold;
pub mod highlight;
pub mod keys;
pub mod marks;
+15
View File
@@ -157,3 +157,18 @@ fn hostile_fences_do_not_panic() {
assert_eq!(job.text, text);
}
}
#[test]
fn hidden_ranges_keep_every_byte_in_the_job_and_shrink_only_those_bytes() {
let colors = notescraft_editor::colors::EditorColors::fallback_dark();
let text = "# One\nbody one\n## Two\nbody two\n";
let hidden = notescraft_editor::fold::hidden_ranges(text, &["## Two".to_owned()].into());
assert_eq!(hidden.len(), 1);
let job = notescraft_editor::highlight::layout_job_folded(text, &colors, 16.0, 400.0, &|_| true, &hidden);
assert_eq!(job.text, text, "no byte is added or lost");
let tiny: usize =
job.sections.iter().filter(|section| section.format.font_id.size < 1.0).map(|section| section.byte_range.end.0 - section.byte_range.start.0).sum();
assert_eq!(tiny, hidden.first().map_or(0, |range| range.end - range.start), "exactly the folded bytes are tiny");
let plain = notescraft_editor::highlight::layout_job(text, &colors, 16.0, 400.0, &|_| true);
assert!(plain.sections.iter().all(|section| section.format.font_id.size >= 1.0));
}
+5 -1
View File
@@ -18,7 +18,8 @@ use crate::session::Session;
/// The commands that delete or rename vault content: they need
/// `assistant.allow_delete` on top of `assistant.allow_write`.
pub const DELETE_COMMANDS: &[&str] = &["note.delete", "note.merge", "trash.empty", "folder.delete", "calendar.delete", "calendar.bulk_delete"];
pub const DELETE_COMMANDS: &[&str] =
&["note.delete", "note.merge", "trash.empty", "workspace.delete", "folder.delete", "calendar.delete", "calendar.bulk_delete"];
/// The core plugin a command belongs to, for the per-plugin tool toggle of
/// Settings, AI assistant, Plugin tools. Core plugins are on until the user
@@ -48,6 +49,7 @@ pub const WRITE_COMMANDS: &[&str] = &[
"note.rename",
"note.extract",
"trash.restore",
"workspace.save",
"note.merge",
"note.property_set",
"note.property_remove",
@@ -110,6 +112,8 @@ pub const WRITE_COMMANDS: &[&str] = &[
pub const READ_COMMANDS: &[&str] = &[
"vault.info",
"trash.list",
"workspace.list",
"workspace.load",
"note.list",
"note.read",
"note.properties",
+38
View File
@@ -85,6 +85,17 @@ fn core_specs() -> Vec<CommandSpec<Session>> {
CommandSpec::new("trash.restore", "Restore from the trash", "Puts a deleted item back where it was", writable, trash_restore)
.with_param(ParamSpec::required("id", ParamKind::String, "the id `trash.list` gave")),
CommandSpec::new("trash.empty", "Empty the trash", "Erases everything in the trash for good", writable, trash_empty),
CommandSpec::new("workspace.list", "List workspaces", "Lists the saved workspaces", |_| true, workspace_list),
CommandSpec::new("workspace.save", "Save workspace", "Saves a layout under a name", writable, workspace_save)
.with_param(ParamSpec::required("name", ParamKind::String, "the workspace name"))
.with_param(ParamSpec::required("layout", ParamKind::Object, "the layout document")),
CommandSpec::new("workspace.load", "Load workspace", "Returns the layout saved under a name", |_| true, workspace_load)
.with_param(ParamSpec::required("name", ParamKind::String, "the workspace name")),
CommandSpec::new("workspace.delete", "Delete workspace", "Deletes a saved workspace", writable, workspace_delete).with_param(ParamSpec::required(
"name",
ParamKind::String,
"the workspace name",
)),
CommandSpec::new("note.delete", "Delete note", "Moves a note to the trash", writable, note_delete).with_param(ParamSpec::required(
"path",
ParamKind::String,
@@ -251,6 +262,33 @@ fn trash_empty(session: &mut Session, _params: &Value) -> CommandResult {
json(json!({ "emptied": true }))
}
/// `workspace.list`: the saved workspaces.
fn workspace_list(session: &mut Session, _params: &Value) -> CommandResult {
json(json!({ "workspaces": session.workspace_names() }))
}
/// `workspace.save`: stores a layout.
fn workspace_save(session: &mut Session, params: &Value) -> CommandResult {
let name = text_param(params, "name")?;
let layout = params.get("layout").cloned().ok_or_else(|| CommandError::new("invalid_params", "`layout` must be an object"))?;
session.save_workspace(&name, &layout).map_err(|error| command_error(&error))?;
json(json!({ "name": name }))
}
/// `workspace.load`: a stored layout.
fn workspace_load(session: &mut Session, params: &Value) -> CommandResult {
let name = text_param(params, "name")?;
let layout = session.load_workspace(&name).map_err(|error| command_error(&error))?;
json(json!({ "name": name, "layout": layout }))
}
/// `workspace.delete`: removes a stored layout.
fn workspace_delete(session: &mut Session, params: &Value) -> CommandResult {
let name = text_param(params, "name")?;
session.delete_workspace(&name).map_err(|error| command_error(&error))?;
json(json!({ "name": name, "deleted": true }))
}
/// `note.delete`: moves a note to the trash.
fn note_delete(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
+13
View File
@@ -107,6 +107,19 @@ impl FilesConfig {
}
}
/// [`FilesConfig::link_text`] pointing inside the note: `subpath` is a heading (`Heading`) or a block (`^abc123`).
#[must_use]
pub fn link_text_to(&self, from: &str, target: &str, subpath: &str) -> String {
let plain = self.link_text(from, target, None);
match self.link_format {
LinkFormat::Wikilink => plain.strip_suffix("]]").map_or(plain.clone(), |open| format!("{open}#{subpath}]]")),
LinkFormat::Markdown => {
let encoded = subpath.replace(' ', "%20");
plain.strip_suffix(')').map_or(plain.clone(), |open| format!("{open}#{encoded})"))
}
}
}
/// Whether the search skips `path` because it lies in an excluded folder.
#[must_use]
pub fn is_excluded(&self, path: &str) -> bool {
+1
View File
@@ -14,5 +14,6 @@ pub(crate) mod pdf_preview;
pub(crate) mod properties;
pub(crate) mod session;
pub(crate) mod trash;
pub mod workspaces;
pub use find::switcher;
pub(crate) use find::{content_search, random_unique, search};
+107
View File
@@ -0,0 +1,107 @@
//! Saved workspaces: a named layout (open tabs, panel sizes, sidebar tab) kept as one JSON file in the vault's
//! private folder. The engine stores the document as the app wrote it; the app decides what it holds.
use serde_json::Value;
use crate::error::{Error, Result};
use crate::vault::session::Session;
/// Most workspaces kept.
pub const MAX_WORKSPACES: usize = 64;
/// Longest workspace name, in characters.
pub const MAX_NAME_CHARS: usize = 64;
/// Largest workspace document, in bytes.
pub const MAX_BYTES: usize = 256 * 1024;
/// The folder under the vault root that holds the workspace files.
const FOLDER: &str = ".notescraft/workspaces";
/// Whether `name` is usable as a workspace name: 1 to [`MAX_NAME_CHARS`] characters, none a path separator, control
/// character or reserved in a file name, not starting with a dot.
#[must_use]
pub fn is_valid_name(name: &str) -> bool {
let count = name.chars().count();
(1..=MAX_NAME_CHARS).contains(&count)
&& name == name.trim()
&& !name.starts_with('.')
&& !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
}
fn invalid(name: &str) -> Error {
Error::InvalidPath(format!("`{name}` is not a valid workspace name"))
}
fn file_of(name: &str) -> String {
format!("{FOLDER}/{name}.json")
}
impl Session {
/// The names of the saved workspaces, sorted.
#[must_use]
pub fn workspace_names(&self) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(self.vault.root().join(FOLDER)) else { return Vec::new() };
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| entry.file_name().to_str().and_then(|name| name.strip_suffix(".json")).map(str::to_owned))
.filter(|name| is_valid_name(name))
.collect();
names.sort();
names.truncate(MAX_WORKSPACES);
names
}
/// Saves `layout` as the workspace `name`, replacing one of that name.
///
/// # Errors
///
/// [`Error::InvalidPath`] for a bad name, [`Error::Vault`] for a document over [`MAX_BYTES`] or when
/// [`MAX_WORKSPACES`] others exist, [`Error::ReadOnly`], or whatever writing returns.
pub fn save_workspace(&mut self, name: &str, layout: &Value) -> Result<()> {
if !is_valid_name(name) {
return Err(invalid(name));
}
let text = serde_json::to_string_pretty(layout).map_err(|error| Error::Vault(error.to_string()))?;
if text.len() > MAX_BYTES {
return Err(Error::Vault(format!("a workspace is at most {MAX_BYTES} bytes")));
}
let names = self.workspace_names();
if !names.iter().any(|known| known == name) && names.len() >= MAX_WORKSPACES {
return Err(Error::Vault(format!("at most {MAX_WORKSPACES} workspaces")));
}
self.vault.write_private(&file_of(name), text.as_bytes())?;
Ok(())
}
/// The workspace `name`.
///
/// # Errors
///
/// [`Error::InvalidPath`] for a bad name, [`Error::NotFound`] when there is none, [`Error::Vault`] for a file
/// that is not valid JSON.
pub fn load_workspace(&self, name: &str) -> Result<Value> {
if !is_valid_name(name) {
return Err(invalid(name));
}
let path = self.vault.root().join(file_of(name));
let bytes = std::fs::read(&path).map_err(|_| Error::NotFound(format!("no workspace named `{name}`")))?;
if bytes.len() > MAX_BYTES {
return Err(Error::Vault(format!("workspace `{name}` is over {MAX_BYTES} bytes")));
}
serde_json::from_slice(&bytes).map_err(|error| Error::Vault(format!("workspace `{name}`: {error}")))
}
/// Deletes the workspace `name`.
///
/// # Errors
///
/// [`Error::InvalidPath`] for a bad name, [`Error::NotFound`] when there is none, [`Error::ReadOnly`].
pub fn delete_workspace(&mut self, name: &str) -> Result<()> {
if !is_valid_name(name) {
return Err(invalid(name));
}
if self.vault.is_read_only() {
return Err(Error::ReadOnly);
}
std::fs::remove_file(self.vault.root().join(file_of(name))).map_err(|_| Error::NotFound(format!("no workspace named `{name}`")))
}
}
+4
View File
@@ -36,6 +36,10 @@ const CORE_IDS: &[&str] = &[
"note.delete",
"note.extract",
"trash.empty",
"workspace.delete",
"workspace.list",
"workspace.load",
"workspace.save",
"trash.list",
"trash.restore",
"note.merge",
+45
View File
@@ -0,0 +1,45 @@
//! Saved workspaces: `workspace.save`, `workspace.list`, `workspace.load`, `workspace.delete`.
mod common;
use common::{temp_config, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::json;
#[test]
fn a_layout_is_saved_listed_loaded_replaced_and_deleted() {
let vault = temp_vault("workspaces");
let config = temp_config("workspaces");
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open(vault.path()).expect("open");
session.use_settings_dir(config.path());
let layout = json!({"tabs": ["a.md", "b.md"], "active": 1});
registry.run("workspace.save", &mut session, &json!({"name": "Writing", "layout": layout})).expect("save");
registry.run("workspace.save", &mut session, &json!({"name": "Review", "layout": {"tabs": []}})).expect("save");
assert_eq!(registry.run("workspace.list", &mut session, &json!({})).expect("list")["workspaces"], json!(["Review", "Writing"]));
assert_eq!(registry.run("workspace.load", &mut session, &json!({"name": "Writing"})).expect("load")["layout"], layout);
registry.run("workspace.save", &mut session, &json!({"name": "Writing", "layout": {"tabs": ["c.md"]}})).expect("replace");
assert_eq!(registry.run("workspace.load", &mut session, &json!({"name": "Writing"})).expect("load")["layout"]["tabs"], json!(["c.md"]));
registry.run("workspace.delete", &mut session, &json!({"name": "Review"})).expect("delete");
assert_eq!(registry.run("workspace.list", &mut session, &json!({})).expect("list")["workspaces"], json!(["Writing"]));
assert!(registry.run("workspace.load", &mut session, &json!({"name": "Review"})).is_err());
assert!(registry.run("workspace.delete", &mut session, &json!({"name": "Review"})).is_err());
}
#[test]
fn hostile_names_and_oversized_layouts_are_refused() {
let vault = temp_vault("workspaces-bad");
let config = temp_config("workspaces-bad");
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open(vault.path()).expect("open");
session.use_settings_dir(config.path());
for name in ["", " pad", ".hidden", "../up", "a/b", "a\\b", "x:y", &"n".repeat(65), "tab\tname"] {
assert!(registry.run("workspace.save", &mut session, &json!({"name": name, "layout": {}})).is_err(), "{name:?}");
assert!(registry.run("workspace.load", &mut session, &json!({"name": name})).is_err(), "{name:?}");
}
let big = json!({"x": "y".repeat(300_000)});
assert!(registry.run("workspace.save", &mut session, &json!({"name": "big", "layout": big})).is_err());
assert_eq!(registry.run("workspace.list", &mut session, &json!({})).expect("list")["workspaces"], json!([]));
}
+13
View File
@@ -101,6 +101,8 @@ conflict = "External file changes conflict with unsaved notes"
no-link-here = "No link at cursor"
no-fold-here = "No fold at cursor"
restored = "Restored"
workspace-saved = "Workspace saved:"
workspace-loaded = "Workspace loaded:"
whiteboard-exported = "Exported whiteboard to"
[ui.assistant-panel]
@@ -216,6 +218,9 @@ discard = "Discard"
cancel = "Cancel"
[ui.graph-view]
timelapse = "Timelapse"
play = "Play"
pause = "Pause"
indexing = "The graph appears when the vault is read. Indexing"
orphans = "Orphans"
unresolved = "Unresolved"
@@ -355,6 +360,12 @@ sample-table-row = "Write notes"
sample-table-date = "Oct 20"
[ui.blocks]
copy-block-link = "Copy link to this block"
copy-block-link-doc = "Copies [[Note#^id]] for the block at the caret, adding the id to the block when it has none"
copy-heading-link = "Copy link to this heading"
copy-heading-link-doc = "Copies [[Note#Heading]] for the heading the caret is under"
link-copied = "Link copied"
no-heading-here = "No heading above the caret"
block = "Block"
turn-into = "Turn into"
turn-into-desc = "Paragraph, heading, quote"
@@ -390,12 +401,14 @@ no-match = "No matching commands"
search-notes = "Open a note…"
search-folders = "Move to which folder?"
vault-root = "Vault root"
search-workspaces = "Load which workspace?"
path-join = "›"
hint = "↑↓ move ↵ run esc close"
[ui.right-view]
no-headings = "No headings"
outline-title = "HEADINGS"
outline-copy-link = "Copy link to this heading"
outline-filter = "Filter headings"
outline-no-match = "No heading matches"
outline-collapse-all = "Fold all"
+21 -5
View File
@@ -44,6 +44,15 @@ pub fn level_indent(level: u8) -> f32 {
f32::from(level.saturating_sub(1)) * LEVEL_INDENT
}
/// What the user did in the outline.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct OutlineOut {
/// A heading to jump to (a click on its row).
pub jump: Option<usize>,
/// A heading whose link to copy (its context menu).
pub copy_link: Option<usize>,
}
/// Height of one outline row, in points.
const ROW_HEIGHT: f32 = 28.0;
/// Width of the fold chevron slot, in points.
@@ -185,14 +194,14 @@ fn filter_box(ui: &mut egui::Ui, tokens: &Tokens, filter: &mut String) {
/// Shows the outline: a header with the heading count and a fold-all control, a filter box and the heading
/// tree, which scrolls. A heading with deeper ones under it folds from its chevron; the heading the caret is
/// under (`active`) is marked. Returns the index of the entry that was clicked (a jump to that heading).
pub fn show_outline(ui: &mut egui::Ui, tokens: &Tokens, entries: &[OutlineEntry], active: Option<usize>) -> Option<usize> {
pub fn show_outline(ui: &mut egui::Ui, tokens: &Tokens, entries: &[OutlineEntry], active: Option<usize>) -> OutlineOut {
let colors = &tokens.theme.colors;
let mut state = ui.ctx().data(|data| data.get_temp::<PaneState>(state_id())).unwrap_or_default();
let sign = signature(entries);
if state.signature != sign {
state = PaneState { signature: sign, ..PaneState::default() };
}
let mut clicked = None;
let mut out = OutlineOut::default();
let parents: BTreeSet<usize> = (0..entries.len()).filter(|index| has_children(entries, *index)).collect();
header(ui, tokens, entries.len(), &parents, &mut state);
filter_box(ui, tokens, &mut state.filter);
@@ -249,13 +258,20 @@ pub fn show_outline(ui: &mut egui::Ui, tokens: &Tokens, entries: &[OutlineEntry]
job.wrap.overflow_character = Some('\u{2026}');
let galley = ui.painter().layout_job(job);
ui.painter().galley(egui::pos2(text_left, rect.center().y - galley.size().y / 2.0), galley, tint);
if response.on_hover_text(&entry.text).clicked() && !toggled {
clicked = Some(index);
let response = response.on_hover_text(&entry.text);
if response.clicked() && !toggled {
out.jump = Some(index);
}
response.context_menu(|ui| {
if ui.button(strings::copy_link()).clicked() {
out.copy_link = Some(index);
ui.close();
}
});
}
});
ui.ctx().data_mut(|data| data.insert_temp(state_id(), state));
clicked
out
}
#[cfg(test)]
+6 -2
View File
@@ -139,6 +139,8 @@ pub struct RightResponse {
pub open_note: Option<String>,
/// An outline entry was clicked; jump to that heading.
pub outline_click: Option<usize>,
/// An outline entry's context menu asked for its link to be copied.
pub outline_copy: Option<usize>,
/// An unlinked mention's `Link` button was pressed; index into the unlinked list.
pub link_mention: Option<usize>,
/// A related note's name was clicked; open that note.
@@ -186,8 +188,10 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu
RightTab::Outline => {
if inputs.outline.is_empty() {
ui.label(egui::RichText::new(strings::no_headings()).color(color(tokens.theme.colors.text_muted)));
} else if let Some(index) = outline::show_outline(ui, tokens, inputs.outline, inputs.active_entry) {
response.outline_click = Some(index);
} else {
let shown = outline::show_outline(ui, tokens, inputs.outline, inputs.active_entry);
response.outline_click = shown.jump;
response.outline_copy = shown.copy_link;
}
}
RightTab::Assistant => {}
+30 -1
View File
@@ -21,6 +21,8 @@ const MOVE_DOWN: &str = "editor.block_move_down";
const NEXT: &str = "editor.block_next";
const PREV: &str = "editor.block_prev";
const MENU: &str = "editor.block_menu";
const COPY_BLOCK_LINK: &str = "editor.copy_block_link";
const COPY_HEADING_LINK: &str = "editor.copy_heading_link";
const EDIT_MENU: &str = "Edit";
/// Gap between the caret and the menu opened from the keyboard, in points.
const CARET_GAP: f32 = 6.0;
@@ -38,6 +40,8 @@ impl NotesApp {
(NEXT, strings::next_block(), strings::next_block()),
(PREV, strings::prev_block(), strings::prev_block()),
(MENU, strings::block_menu(), strings::block_menu_doc()),
(COPY_BLOCK_LINK, strings::copy_block_link(), strings::copy_block_link_doc()),
(COPY_HEADING_LINK, strings::copy_heading_link(), strings::copy_heading_link_doc()),
];
for (id, label, doc) in commands {
let mut spec = CommandSpec::new(id, label, doc, |_: &UiState| true, crate::shell::app::save_stub);
@@ -97,7 +101,7 @@ impl NotesApp {
/// Runs a block command from the palette or Vim; `false` when `id` is not one.
pub(in crate::shell::app) fn run_block_command(&mut self, id: &str, ctx: &egui::Context) -> bool {
if ![MOVE_UP, MOVE_DOWN, NEXT, PREV, MENU].contains(&id) {
if ![MOVE_UP, MOVE_DOWN, NEXT, PREV, MENU, COPY_BLOCK_LINK, COPY_HEADING_LINK].contains(&id) {
return false;
}
let Some(path) = self.active_note_path() else { return true };
@@ -128,6 +132,31 @@ impl NotesApp {
self.place_caret(&path, start, ctx);
}
}
COPY_BLOCK_LINK => {
let salt = path.bytes().fold(0_u64, |hash, byte| hash.wrapping_mul(131).wrapping_add(u64::from(byte)));
let Some((block_id, insertion)) = notescraft_editor::blocks::ensure_block_id(&text, offset, salt) else {
self.toast = Some(strings::no_block().to_owned());
return true;
};
if let Some((position, added)) = insertion {
if self.session.is_read_only() {
return true;
}
self.replace_source(&path, position..position, &added, now);
}
ctx.copy_text(self.session.config().files.link_text_to(&path, &path, &format!("^{block_id}")));
self.toast = Some(strings::link_copied().to_owned());
}
COPY_HEADING_LINK => {
let headings = notescraft_editor::fold::headings(&text);
let Some(heading) = headings.iter().rev().find(|heading| heading.start <= offset) else {
self.toast = Some(strings::no_heading_here().to_owned());
return true;
};
let title = heading.key.trim_start_matches('#').trim().trim_end_matches('#').trim();
ctx.copy_text(self.session.config().files.link_text_to(&path, &path, title));
self.toast = Some(strings::link_copied().to_owned());
}
_ => {
let anchor = block_menu::caret(ctx).map_or_else(|| ctx.viewport_rect().center(), |rect| rect.left_bottom() + egui::vec2(0.0, CARET_GAP));
block_menu::open(ctx, BlockMenu { path, offset, anchor, page: Page::Main });
@@ -152,3 +152,25 @@ fn vim_block_keys_jump_between_blocks_and_gb_opens_the_menu() {
assert!(crate::block_menu::current(&ctx).is_some(), "gb opens the block menu");
assert_eq!(text(&app), TEXT);
}
#[test]
fn copying_a_block_link_adds_the_id_once_and_copying_a_heading_link_needs_no_edit() {
let Some(mut app) = app("blocks-copy-links", ViewMode::Markdown) else { return };
let ctx = egui::Context::default();
let at = TEXT.find("Beta").unwrap_or_default() + 1;
if let Some(tab) = app.editing.tabs.get_mut("note.md") {
tab.editor.cursor = Some(at);
}
assert!(app.run_block_command("editor.copy_block_link", &ctx));
let first = text(&app);
let id = first.lines().find(|line| line.starts_with("Beta ^")).and_then(|line| line.strip_prefix("Beta ^")).map(str::to_owned).unwrap_or_default();
assert_eq!(id.len(), 6, "{first:?}");
assert_eq!(app.toast.as_deref(), Some("Link copied"));
assert!(app.run_block_command("editor.copy_block_link", &ctx));
assert_eq!(text(&app), first, "a block that has an id gets no second one");
let after = app.session.config().files.link_text_to("note.md", "note.md", &format!("^{id}"));
assert_eq!(after, format!("[[note#^{id}]]"));
assert!(app.run_block_command("editor.copy_heading_link", &ctx));
assert_eq!(text(&app), first, "a heading link edits nothing");
assert_eq!(app.session.config().files.link_text_to("a/x.md", "b/y.md", "My head"), "[[y#My head]]");
}
@@ -141,6 +141,15 @@ impl NotesApp {
tab.view = crate::center::ViewMode::Visual;
}
}
if std::mem::take(&mut self.state.workspace_save_request) {
self.menus.prompt = Some((
crate::context_menu::MenuAction { kind: crate::context_menu::MenuKind::SaveWorkspace, path: String::new(), folder: false },
String::new(),
));
}
if std::mem::take(&mut self.state.workspace_load_request) {
self.palette.open_as(crate::palette::PaletteKind::Workspaces);
}
if std::mem::take(&mut self.state.trash_request) {
self.open_note(crate::tabs::TRASH_PATH);
}
+49 -19
View File
@@ -60,25 +60,8 @@ impl History {
/// A command body over the UI state.
type Handler = fn(&mut UiState, &serde_json::Value) -> notescraft_engine::CommandResult;
/// Registers `nav.back` and `nav.forward`.
///
/// # Errors
/// Registry validation errors.
pub fn register(registry: &mut Registry<UiState>) -> Result<(), notescraft_engine::Error> {
let steps: [(&str, &str, Handler); 2] =
[("nav.back", "Go back", |state, _| ask(state, NavRequest::Back)), ("nav.forward", "Go forward", |state, _| ask(state, NavRequest::Forward))];
let mut find = CommandSpec::new(
"find.open",
"Find in note",
"Opens the find and replace bar",
|_| true,
|state: &mut UiState, _| {
state.find_requested = true;
Ok(serde_json::Value::Null)
},
);
find.shortcut = Some("Mod-f".to_owned());
registry.register(find)?;
/// The View menu's commands: split, stacked tabs, pop-out, performance, reading mode, trash and workspaces.
fn register_view_commands(registry: &mut Registry<UiState>) -> Result<(), notescraft_engine::Error> {
let mut split = CommandSpec::new(
"view.split",
"Split the view",
@@ -151,6 +134,53 @@ pub fn register(registry: &mut Registry<UiState>) -> Result<(), notescraft_engin
);
trash.menu_path = vec![String::from("View"), String::from("Open the trash")];
registry.register(trash)?;
let mut save_workspace = CommandSpec::new(
"view.save_workspace",
"Save workspace",
"Saves the open tabs and panel sizes under a name",
|_| true,
|state: &mut UiState, _| {
state.workspace_save_request = true;
Ok(serde_json::Value::Null)
},
);
save_workspace.menu_path = vec![String::from("View"), String::from("Save workspace")];
registry.register(save_workspace)?;
let mut load_workspace = CommandSpec::new(
"view.load_workspace",
"Load workspace",
"Puts a saved set of tabs and panel sizes back",
|_| true,
|state: &mut UiState, _| {
state.workspace_load_request = true;
Ok(serde_json::Value::Null)
},
);
load_workspace.menu_path = vec![String::from("View"), String::from("Load workspace")];
registry.register(load_workspace)?;
Ok(())
}
/// Registers `nav.back` and `nav.forward`.
///
/// # Errors
/// Registry validation errors.
pub fn register(registry: &mut Registry<UiState>) -> Result<(), notescraft_engine::Error> {
let steps: [(&str, &str, Handler); 2] =
[("nav.back", "Go back", |state, _| ask(state, NavRequest::Back)), ("nav.forward", "Go forward", |state, _| ask(state, NavRequest::Forward))];
let mut find = CommandSpec::new(
"find.open",
"Find in note",
"Opens the find and replace bar",
|_| true,
|state: &mut UiState, _| {
state.find_requested = true;
Ok(serde_json::Value::Null)
},
);
find.shortcut = Some("Mod-f".to_owned());
registry.register(find)?;
register_view_commands(registry)?;
for (id, label, run) in steps {
let mut spec = CommandSpec::new(id, label, "Moves through the notes visited, like a browser", |_| true, run);
spec.shortcut = Some(if id == "nav.back" { "Mod-[" } else { "Mod-]" }.to_owned());
@@ -12,3 +12,4 @@ pub(super) mod link_preview;
pub(super) mod preferences;
pub(super) mod switcher;
pub(super) mod vault_switch;
pub(super) mod workspaces;
@@ -0,0 +1,76 @@
//! Saved workspaces in the app: what a layout holds, saving the current one, and putting a saved one back.
use serde_json::{Value, json};
use crate::shell::app::NotesApp;
use crate::tabs::TabKind;
impl NotesApp {
/// The layout document of the window as it is now: tabs in order, the active one, pins, panel sizes and the
/// sidebar's tab.
pub(in crate::shell::app) fn current_layout(&self) -> Value {
json!({
"tabs": self.tabs.open,
"active": self.tabs.active_path(),
"pinned": self.tabs.pinned,
"sidebar_tab": self.state.sidebar_tab,
"sidebar_width": self.state.sidebar_width,
"right_width": self.state.right_width,
"sidebar_visible": self.state.sidebar_visible,
"right_visible": self.state.right_visible,
})
}
/// Saves the current layout under `name`.
pub(in crate::shell::app) fn save_workspace_named(&mut self, name: &str) {
let layout = self.current_layout();
if self.engine_command("workspace.save", &json!({"name": name, "layout": layout})).is_some() {
self.toast = Some(format!("{} {name}", crate::app::strings::workspace_saved()));
}
}
/// Puts the workspace `name` back: the tabs it names open (notes that no longer exist are skipped), the others
/// close (one with unsaved changes asks first and stays), then the pins, panel sizes and sidebar tab apply.
pub(in crate::shell::app) fn load_workspace_named(&mut self, name: &str) {
let Some(done) = self.engine_command("workspace.load", &json!({"name": name})) else { return };
let layout = done.get("layout").cloned().unwrap_or(Value::Null);
let wanted: Vec<String> =
layout.get("tabs").and_then(Value::as_array).map(|tabs| tabs.iter().filter_map(Value::as_str).map(str::to_owned).collect()).unwrap_or_default();
let root = self.session.vault().root().to_path_buf();
let exists = |path: &String| !TabKind::of(path).is_file() || root.join(path).exists();
let wanted: Vec<String> = wanted.into_iter().filter(exists).take(crate::tabs::MAX_TABS).collect();
let open_now: Vec<String> = self.tabs.open.clone();
for path in open_now.iter().filter(|path| !wanted.contains(path)) {
self.close_note(path);
}
for path in &wanted {
self.open_note(path);
}
if let Some(active) = layout.get("active").and_then(Value::as_str).filter(|active| wanted.iter().any(|path| path == active)) {
self.open_note(active);
}
for path in layout.get("pinned").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {
if wanted.iter().any(|tab| tab == path) && !self.tabs.is_pinned(path) {
self.tabs.toggle_pin(path);
}
}
if let Some(tab) = layout.get("sidebar_tab").and_then(|tab| serde_json::from_value(tab.clone()).ok()) {
self.state.sidebar_tab = tab;
}
for (key, slot) in [("sidebar_width", &mut self.state.sidebar_width), ("right_width", &mut self.state.right_width)] {
if let Some(width) = layout.get(key).and_then(Value::as_f64).filter(|width| width.is_finite()) {
#[allow(clippy::cast_possible_truncation)]
let width = width as f32;
*slot = width;
}
}
for (key, slot) in [("sidebar_visible", &mut self.state.sidebar_visible), ("right_visible", &mut self.state.right_visible)] {
if let Some(visible) = layout.get(key).and_then(Value::as_bool) {
*slot = visible;
}
}
self.sync_state();
self.right_valid = false;
self.toast = Some(format!("{} {name}", crate::app::strings::workspace_loaded()));
}
}
+12 -3
View File
@@ -151,6 +151,7 @@ impl NotesApp {
rows.extend(self.tree.folder_paths().into_iter().map(|folder| commands_ui::folder_row(folder.clone(), folder)));
rows
}
PaletteKind::Workspaces => self.session.workspace_names().into_iter().map(|name| commands_ui::folder_row(name.clone(), name)).collect(),
PaletteKind::Notes if crate::shell::app::vault::switcher::is_deep(&self.palette.query) => {
crate::shell::app::vault::switcher::rows(&self.session, &self.palette.query)
}
@@ -255,7 +256,7 @@ impl NotesApp {
self.toast = Some(error);
}
self.handle_lint(&right.lint, now, ctx);
self.handle_right(right, now);
self.handle_right(right, now, ctx);
self.tick(now);
if edited || self.editing.tabs.values().any(|tab| tab.last_change.is_some()) {
// Come back when the autosave and recovery timers are due.
@@ -300,7 +301,9 @@ impl NotesApp {
let _ = self.views.images.show_lightbox(ctx, &self.tokens);
let picked = crate::palette::show_palette(ctx, &self.tokens, &mut self.palette, rows);
if let Some(id) = picked {
if self.palette.kind == PaletteKind::Folders {
if self.palette.kind == PaletteKind::Workspaces {
self.load_workspace_named(&id);
} else if self.palette.kind == PaletteKind::Folders {
if let Some((from, is_folder)) = self.move_from.take() {
self.move_to_folder(&from, is_folder, &id);
}
@@ -549,7 +552,7 @@ impl NotesApp {
/// Reacts to right-card actions: open a mentioned note, link an unlinked mention,
/// jump to a heading from the outline (Source mode only).
fn handle_right(&mut self, response: RightResponse, now: f64) {
fn handle_right(&mut self, response: RightResponse, now: f64, ctx: &egui::Context) {
self.handle_assistant(response.assistant);
if let Some(path) = response.open_note {
self.open_note(&path);
@@ -569,6 +572,12 @@ impl NotesApp {
if let Some(entry) = response.outline_click {
self.jump_to_outline(entry);
}
if let Some(entry) = response.outline_copy
&& let (Some(path), Some(title)) = (self.tabs.active_path().map(str::to_owned), self.right.outline.get(entry).map(|entry| entry.text.clone()))
{
ctx.copy_text(self.session.config().files.link_text_to(&path, &path, &title));
self.toast = Some(crate::text::blocks::link_copied().to_owned());
}
}
/// Outline click: in Source mode the caret jumps to the heading byte and the editor
@@ -195,3 +195,26 @@ fn move_to_opens_the_folder_picker_and_moves_a_file_or_folder_but_never_into_its
app.move_to_folder("box", true, "dest");
assert!(root.join("dest/box/inner").is_dir());
}
#[test]
fn a_saved_workspace_brings_the_tabs_and_the_active_one_back_and_skips_notes_that_are_gone() {
let Some(mut app) = fixture("menu-workspace") else { return };
let root = app.session.vault().root().to_path_buf();
app.state.sidebar_width = 333.0;
app.save_workspace_named("Trio");
assert!(app.toast.as_deref().is_some_and(|toast| toast.contains("Trio")), "{:?}", app.toast);
assert_eq!(app.session.workspace_names(), ["Trio"]);
app.close_note("b.md");
app.close_note("c.md");
app.state.sidebar_width = 250.0;
assert_eq!(app.tabs.open, ["a.md"]);
app.load_workspace_named("Trio");
assert_eq!(app.tabs.open, ["a.md", "b.md", "c.md"]);
assert_eq!(app.tabs.active_path(), Some("c.md"), "the tab that was active is active again");
assert!((app.state.sidebar_width - 333.0).abs() < f32::EPSILON, "the panel size came back too");
assert!(std::fs::remove_file(root.join("b.md")).is_ok());
app.close_note("a.md");
app.close_note("b.md");
app.load_workspace_named("Trio");
assert!(app.tabs.is_open("a.md") && app.tabs.is_open("c.md") && !app.tabs.is_open("b.md"), "a note that is gone is skipped: {:?}", app.tabs.open);
}
+7 -1
View File
@@ -14,6 +14,7 @@ const DELETE_BODY: &str = "Unsaved notes will ask to save or discard before dele
const RENAME_TITLE: &str = "Rename";
const NEW_NOTE_TITLE: &str = "New note";
const NEW_FOLDER_TITLE: &str = "New folder";
const SAVE_WORKSPACE_TITLE: &str = "Save workspace as";
const MERGE_SAVE_FIRST: &str = "Save the note before merging it";
const EXTRACT_TITLE: &str = "Extract to a new note";
/// Longest default name offered for an extracted note, in characters.
@@ -68,7 +69,7 @@ impl NotesApp {
MenuKind::OpenInSplit => self.open_in_split(path),
MenuKind::Bookmark => self.toggle_bookmark(path),
MenuKind::Pin => self.toggle_pin(path),
MenuKind::NewNote | MenuKind::NewFolder | MenuKind::Rename | MenuKind::Delete | MenuKind::Extract => {
MenuKind::NewNote | MenuKind::NewFolder | MenuKind::Rename | MenuKind::Delete | MenuKind::Extract | MenuKind::SaveWorkspace => {
let name = if kind == MenuKind::Rename { path.rsplit('/').next().unwrap_or(path).to_owned() } else { String::new() };
self.menus.prompt = Some((action, name));
}
@@ -124,6 +125,7 @@ impl NotesApp {
MenuKind::Rename => RENAME_TITLE,
MenuKind::NewFolder => NEW_FOLDER_TITLE,
MenuKind::Extract => EXTRACT_TITLE,
MenuKind::SaveWorkspace => SAVE_WORKSPACE_TITLE,
_ => NEW_NOTE_TITLE,
};
let delete = action.kind == MenuKind::Delete;
@@ -159,6 +161,10 @@ impl NotesApp {
self.request_delete(action);
return;
}
if action.kind == MenuKind::SaveWorkspace {
self.save_workspace_named(value.trim());
return;
}
// A name is one component. The engine validates the complete vault path.
if value.is_empty() || value.contains(['/', '\\']) || [".", ".."].contains(&value) {
self.toast = Some(INVALID_NAME.into());
@@ -158,6 +158,9 @@ impl Views {
crate::graph_view::show_indexing(ui, inputs.tokens, crate::sidebar::index_status::percent(progress));
return;
}
if self.graph_data.is_none() {
self.graph.set_created(crate::graph_view::creation_times(inputs.session));
}
let data = self.graph_data.get_or_insert_with(|| crate::graph_view::graph_data(inputs.session));
if let Some(path) = crate::graph_view::show_graph(ui, inputs.tokens, &mut self.graph, data).open {
response.link = Some(LinkClick { target: path, subpath: None, ctrl: false });
+8
View File
@@ -73,6 +73,12 @@ pub struct UiState {
/// Set by the `view.trash` command: open the trash tab next frame; not saved.
#[serde(skip)]
pub trash_request: bool,
/// Set by `view.save_workspace`: ask for a name next frame; not saved.
#[serde(skip)]
pub workspace_save_request: bool,
/// Set by `view.load_workspace`: open the workspace picker next frame; not saved.
#[serde(skip)]
pub workspace_load_request: bool,
/// Active sidebar panel, defaulting to Explorer for older saved state.
pub sidebar_tab: crate::sidebar_tabs::SidebarTab,
/// Whether the user expanded the right card; narrow-window collapse is transient.
@@ -174,6 +180,8 @@ impl UiState {
performance_request: false,
reading_request: false,
trash_request: false,
workspace_save_request: false,
workspace_load_request: false,
sidebar_tab: crate::sidebar_tabs::SidebarTab::Explorer,
right_visible: true,
sound: true,
+2
View File
@@ -14,4 +14,6 @@ table! {
no_fold_here = "ui.app.no-fold-here";
whiteboard_exported = "ui.app.whiteboard-exported";
restored = "ui.app.restored";
workspace_saved = "ui.app.workspace-saved";
workspace_loaded = "ui.app.workspace-loaded";
}
+1
View File
@@ -6,6 +6,7 @@ table! {
search_notes = "ui.palette.search-notes";
search_folders = "ui.palette.search-folders";
vault_root = "ui.palette.vault-root";
search_workspaces = "ui.palette.search-workspaces";
path_join = "ui.palette.path-join";
hint = "ui.palette.hint";
}
@@ -5,6 +5,7 @@ table! {
outline = "ui.right-view.outline";
assistant = "ui.right-view.assistant";
no_headings = "ui.right-view.no-headings";
copy_link = "ui.right-view.outline-copy-link";
title = "ui.right-view.outline-title";
filter = "ui.right-view.outline-filter";
no_match = "ui.right-view.outline-no-match";
+6
View File
@@ -29,4 +29,10 @@ table! {
prev_block = "ui.blocks.prev-block";
block_menu = "ui.blocks.block-menu";
block_menu_doc = "ui.blocks.block-menu-doc";
copy_block_link = "ui.blocks.copy-block-link";
copy_block_link_doc = "ui.blocks.copy-block-link-doc";
copy_heading_link = "ui.blocks.copy-heading-link";
copy_heading_link_doc = "ui.blocks.copy-heading-link-doc";
link_copied = "ui.blocks.link-copied";
no_heading_here = "ui.blocks.no-heading-here";
}
@@ -10,6 +10,9 @@ table! {
more = "ui.graph-view.more";
hops = "ui.graph-view.hops";
forces = "ui.graph-view.forces";
timelapse = "ui.graph-view.timelapse";
play = "ui.graph-view.play";
pause = "ui.graph-view.pause";
centre_force = "ui.graph-view.centre-force";
repel_force = "ui.graph-view.repel-force";
link_force = "ui.graph-view.link-force";
+1
View File
@@ -20,6 +20,7 @@ use crate::theme::{Tokens, color};
mod breadcrumb;
mod source;
mod source_fold;
mod source_selection;
mod toast;
mod visual_slash;
@@ -49,6 +49,9 @@ pub(super) fn draw_source(ui: &mut Ui, inputs: &mut CenterInputs<'_>, response:
{
paint_line_numbers(ui, inputs.tokens, galley, *origin, inputs.font_size);
}
if let Some((galley, origin)) = &out.galley {
super::source_fold::show(ui, inputs.tokens, galley, *origin, &mut edit.editor, inputs.line_numbers);
}
response.selection = super::source_selection::show(ui, inputs.tokens, id, &edit.editor.text, out.caret_rect);
let has_focus = ui.ctx().memory(|mem| mem.has_focus(id));
if has_focus {
@@ -0,0 +1,57 @@
//! Chevrons in the left margin of the Source view that fold and unfold the section under a heading.
use egui::{Galley, Pos2, Rect, Sense, Ui, Vec2};
use notescraft_editor::editor::EditorState;
use notescraft_editor::fold;
use crate::theme::{Tokens, color};
/// Distance from the text's left edge to the chevron's centre, in points.
const CHEVRON_GAP: f32 = 18.0;
/// The same when line numbers fill the margin next to the text, in points.
const CHEVRON_GAP_NUMBERED: f32 = 44.0;
/// Side of the chevron's hit area, in points.
const HIT: f32 = 20.0;
/// Side of the chevron icon, in points.
const ICON: f32 = 14.0;
/// A heading row shorter than this is inside a folded section and gets no chevron, in points.
const HIDDEN_ROW: f32 = 4.0;
/// Draws a chevron beside every heading that has a section under it and toggles the fold of one that was clicked.
/// Folding moves a caret that sat inside the section to the end of the heading line.
pub(super) fn show(ui: &mut Ui, tokens: &Tokens, galley: &Galley, origin: Pos2, state: &mut EditorState, line_numbers: bool) {
let colors = &tokens.theme.colors;
let found = fold::headings(&state.text);
let mut toggled: Option<(usize, bool)> = None;
for (index, heading) in found.iter().enumerate() {
let Some(body) = fold::body_of(&found, index, &state.text) else { continue };
let Some(before) = state.text.get(..heading.start) else { continue };
let row = galley.pos_from_cursor(egui::text::CCursor::new(before.chars().count()));
if row.height() < HIDDEN_ROW {
continue;
}
let folded = state.folded.contains(&heading.key);
let gap = if line_numbers { CHEVRON_GAP_NUMBERED } else { CHEVRON_GAP };
let centre = Pos2::new(origin.x - gap, origin.y + row.center().y);
let hit = ui.interact(Rect::from_center_size(centre, Vec2::splat(HIT)), ui.id().with(("source-fold", index)), Sense::click());
let tint = if folded {
color(colors.accent)
} else if hit.hovered() {
color(colors.text)
} else {
color(colors.text_muted).gamma_multiply(0.6)
};
crate::icons::paint(ui, if folded { "chevron-right" } else { "chevron-down" }, Rect::from_center_size(centre, Vec2::splat(ICON)), tint);
if hit.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
toggled = Some((index, body.contains(&state.cursor.unwrap_or(usize::MAX))));
}
}
let Some((index, caret_inside)) = toggled else { return };
let Some(heading) = found.get(index) else { return };
if !state.folded.remove(&heading.key) {
state.folded.insert(heading.key.clone());
if caret_inside {
state.jump = Some(heading.line_end);
}
}
}
+21
View File
@@ -17,6 +17,7 @@ mod controls;
mod draw;
mod forces;
mod state;
mod timelapse;
pub use draw::{GraphResponse, show_graph};
pub use state::GraphView;
@@ -100,6 +101,15 @@ pub fn show_indexing(ui: &mut egui::Ui, tokens: &crate::theme::Tokens, percent:
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(crate::style::TEXT_TITLE), muted);
}
/// When each note was created (its file creation time, else its last change), in Unix milliseconds, by path.
#[must_use]
pub fn creation_times(session: &Session) -> HashMap<String, u64> {
session.vault().list().map_or_else(
|_| HashMap::new(),
|entries| entries.into_iter().filter(|entry| !entry.is_dir).filter_map(|entry| Some((entry.path, entry.created.or(entry.modified)?))).collect(),
)
}
/// Builds [`GraphData`] from a session's index.
///
/// Node numbering follows [`notescraft_index::Index::node_ids`], so it matches
@@ -171,6 +181,17 @@ mod tests {
assert_eq!(data.tags.get(1), Some(&Vec::<String>::new()));
}
#[test]
fn every_note_has_a_creation_time() {
let vault = testsupport::temp_vault("graph-created");
testsupport::write_note(&vault, "a", "# A\n");
testsupport::write_note(&vault, "z/b", "# B\n");
let Ok(session) = Session::open_read_only(&vault) else { return };
let times = creation_times(&session);
assert!(times.get("a.md").is_some_and(|time| *time > 1_500_000_000_000), "{times:?}");
assert!(times.contains_key("z/b.md") && !times.contains_key("z"), "files only");
}
#[test]
fn graph_data_collapses_ghost_names() {
let vault = testsupport::temp_vault("graph-ghosts");
@@ -69,10 +69,35 @@ fn body(ui: &mut egui::Ui, tokens: &Tokens, view: &mut GraphView) {
}
ui.label(strings::hops());
});
timelapse(ui, tokens, view);
forces(ui, view);
groups(ui, tokens, view);
}
/// The timelapse: a toggle that starts it, and while it runs a play/pause button, the date it has reached and a slider.
fn timelapse(ui: &mut egui::Ui, tokens: &Tokens, view: &mut GraphView) {
let running = view.timelapse();
if toggle(ui, tokens, strings::timelapse(), running.is_some()) {
view.toggle_timelapse();
}
let Some(run) = running else { return };
ui.horizontal(|ui| {
let label = if run.playing { strings::pause() } else { strings::play() };
if ui.small_button(label).clicked() {
if run.cutoff >= run.last {
view.seek_timelapse(run.first);
}
view.set_timelapse_playing(!run.playing);
}
ui.label(egui::RichText::new(super::timelapse::date_of(run.cutoff)).size(TEXT_TINY));
});
let mut cutoff = run.cutoff;
let slider = ui.add(egui::Slider::new(&mut cutoff, run.first..=run.last).show_value(false));
if slider.changed() {
view.seek_timelapse(cutoff);
}
}
/// The four force sliders; the layout restarts when a slider is released, not on every pixel of a drag.
fn forces(ui: &mut egui::Ui, view: &mut GraphView) {
ui.label(egui::RichText::new(strings::forces()).size(TEXT_TINY));
@@ -43,6 +43,9 @@ pub struct GraphResponse {
pub fn show_graph(ui: &mut egui::Ui, tokens: &Tokens, view: &mut GraphView, data: &GraphData) -> GraphResponse {
view.set_data(data);
view.step(ui.ctx());
if view.advance_timelapse(f64::from(ui.input(|input| input.stable_dt))) {
ui.ctx().request_repaint();
}
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
let area = response.rect;
view.fit_if_pending(area.min, area);
+26 -1
View File
@@ -81,6 +81,14 @@ pub struct GraphView {
pub(super) draft: Forces,
groups: Vec<Group>,
group_of: Vec<Option<usize>>,
/// Creation time of every note by path, as the app gave it.
pub(super) created_by_path: std::collections::HashMap<String, u64>,
/// Creation time of every render node (`None` for ghosts and undated notes).
pub(super) created: Vec<Option<u64>>,
/// Paths of the real nodes of the current data.
paths: Vec<String>,
/// The timelapse, while one is shown.
pub(super) timelapse: Option<super::timelapse::Timelapse>,
}
impl Default for GraphView {
@@ -99,6 +107,10 @@ impl Default for GraphView {
draft: Forces::default(),
groups: Vec::new(),
group_of: Vec::new(),
created_by_path: std::collections::HashMap::new(),
created: Vec::new(),
paths: Vec::new(),
timelapse: None,
}
}
}
@@ -228,6 +240,8 @@ impl GraphView {
let Some(cache) = build(data, &self.inputs) else { return false };
let reshaped = self.node_count() != cache.node_count;
self.cache = Some(cache);
self.paths.clone_from(&data.paths);
self.refresh_created();
self.refresh_groups();
self.fit_pending = reshaped || self.hovered.is_some();
self.auto_fit = true;
@@ -237,6 +251,13 @@ impl GraphView {
true
}
/// Looks the creation time of every render node up again.
pub(super) fn refresh_created(&mut self) {
let Some(cache) = self.cache.as_ref() else { return };
self.created =
cache.real.iter().map(|real| real.and_then(|real| self.paths.get(real)).and_then(|path| self.created_by_path.get(path).copied())).collect();
}
/// Applies the forces: the layout restarts from the seed with them.
pub(super) fn apply_forces(&mut self, forces: Forces) {
self.inputs.forces = forces;
@@ -372,7 +393,11 @@ impl GraphView {
pub fn is_visible(&self, node: usize) -> bool {
let Some(cache) = self.cache.as_ref() else { return false };
match cache.real.get(node).copied().flatten() {
Some(real) => (!self.inputs.orphans_only || cache.orphan_set.contains(&real)) && (!self.inputs.show_tags || cache.tagged.contains(&real)),
Some(real) => {
(!self.inputs.orphans_only || cache.orphan_set.contains(&real))
&& (!self.inputs.show_tags || cache.tagged.contains(&real))
&& self.shown_by_time(node)
}
None => self.inputs.show_unresolved,
}
}
@@ -0,0 +1,141 @@
//! The graph timelapse: the notes appear in the order they were created, so the graph can be watched growing.
use std::collections::HashMap;
use super::state::GraphView;
/// How long a full run from the first note to the last takes, in seconds.
const RUN_SECONDS: f64 = 12.0;
/// A timelapse in progress: notes created after `cutoff` are hidden.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Timelapse {
/// Creation time shown so far, in Unix milliseconds.
pub(super) cutoff: u64,
/// The earliest creation time of any note.
pub(super) first: u64,
/// The latest creation time of any note.
pub(super) last: u64,
/// Whether the cutoff is moving.
pub(super) playing: bool,
}
/// `YYYY-MM-DD` of a Unix time in milliseconds (proleptic Gregorian, UTC).
#[must_use]
pub(super) fn date_of(millis: u64) -> String {
let days = i64::try_from(millis / 86_400_000).unwrap_or(0);
// Howard Hinnant's civil-from-days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let day_of_era = z.rem_euclid(146_097);
let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_index = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_index + 2) / 5 + 1;
let month = if month_index < 10 { month_index + 3 } else { month_index - 9 };
let year = year_of_era + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
impl GraphView {
/// Gives the view the creation time of every note by path; the timelapse and its visibility use it.
pub fn set_created(&mut self, created: HashMap<String, u64>) {
self.created_by_path = created;
self.refresh_created();
}
/// The timelapse state, while one is shown.
pub(super) fn timelapse(&self) -> Option<Timelapse> {
self.timelapse
}
/// Starts a timelapse from the oldest note, or ends the running one. `false` when no note has a date.
pub(super) fn toggle_timelapse(&mut self) -> bool {
if self.timelapse.take().is_some() {
return true;
}
let dates = self.created.iter().flatten();
let (first, last) = dates.fold((u64::MAX, 0_u64), |(first, last), date| (first.min(*date), last.max(*date)));
if first > last {
return false;
}
self.timelapse = Some(Timelapse { cutoff: first, first, last, playing: true });
true
}
/// Plays or pauses the timelapse.
pub(super) fn set_timelapse_playing(&mut self, playing: bool) {
if let Some(run) = self.timelapse.as_mut() {
run.playing = playing;
}
}
/// Moves the cutoff to `cutoff` (the slider), paused.
pub(super) fn seek_timelapse(&mut self, cutoff: u64) {
if let Some(run) = self.timelapse.as_mut() {
run.cutoff = cutoff.clamp(run.first, run.last);
run.playing = false;
}
}
/// Advances a playing timelapse by `seconds`; `true` while it still plays (the caller asks for another frame).
pub(super) fn advance_timelapse(&mut self, seconds: f64) -> bool {
let Some(run) = self.timelapse.as_mut().filter(|run| run.playing) else { return false };
let span = run.last.saturating_sub(run.first).max(1);
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let step = (span as f64 / RUN_SECONDS * seconds.clamp(0.0, 0.25)) as u64;
run.cutoff = run.cutoff.saturating_add(step.max(1)).min(run.last);
if run.cutoff >= run.last {
run.playing = false;
}
run.playing
}
/// Whether the timelapse (if any) has reached render node `node`'s creation time; undated nodes always show.
pub(super) fn shown_by_time(&self, node: usize) -> bool {
match (self.timelapse, self.created.get(node).copied().flatten()) {
(Some(run), Some(date)) => date <= run.cutoff,
_ => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_read_back_as_calendar_days() {
assert_eq!(date_of(0), "1970-01-01");
assert_eq!(date_of(951_782_400_000), "2000-02-29", "a leap day");
assert_eq!(date_of(1_792_000_000_000), "2026-10-14");
assert!(date_of(u64::MAX).len() > 8, "a huge time does not panic");
}
#[test]
fn a_run_reveals_notes_in_order_and_stops_at_the_last() {
let mut view = GraphView::new();
view.created = vec![Some(1_000), Some(5_000), None, Some(9_000)];
assert!(view.toggle_timelapse());
assert_eq!(view.timelapse().map(|run| (run.first, run.last)), Some((1_000, 9_000)));
assert!(view.shown_by_time(0) && !view.shown_by_time(1) && view.shown_by_time(2) && !view.shown_by_time(3));
view.seek_timelapse(5_000);
assert!(view.shown_by_time(1) && !view.shown_by_time(3));
view.set_timelapse_playing(true);
let mut frames = 0;
while view.advance_timelapse(0.1) {
frames += 1;
assert!(frames < 1_000, "the run ends");
}
assert!(view.shown_by_time(3), "everything shows at the end");
assert!(view.toggle_timelapse() && view.timelapse().is_none(), "toggling again ends it and shows everything");
assert!(view.shown_by_time(3));
}
#[test]
fn nothing_dated_means_no_timelapse() {
let mut view = GraphView::new();
assert!(!view.toggle_timelapse());
assert!(view.timelapse().is_none());
}
}
@@ -44,6 +44,8 @@ pub enum MenuKind {
NewFolder,
/// Name the new note a selected passage is moved into.
Extract,
/// Name the workspace the current layout is saved as.
SaveWorkspace,
/// Rename this file or folder.
Rename,
/// Confirm moving this file or folder to trash.
+6
View File
@@ -42,6 +42,8 @@ pub enum PaletteKind {
Notes,
/// Folders, to pick where something moves to.
Folders,
/// Saved workspaces, to load one.
Workspaces,
}
impl PaletteState {
@@ -221,6 +223,7 @@ fn draw_card(ctx: &Context, tokens: &Tokens, state: &mut PaletteState, commands:
let placeholder = match state.kind {
PaletteKind::Notes => strings::search_notes(),
PaletteKind::Folders => strings::search_folders(),
PaletteKind::Workspaces => strings::search_workspaces(),
PaletteKind::Commands => strings::search(),
};
let field = crate::settings_view::controls::input(ui, tokens, &mut state.query, PALETTE_WIDTH - 52.0, placeholder);
@@ -270,6 +273,9 @@ fn icon_for(command: &CommandInfo, kind: PaletteKind) -> &'static str {
if kind == PaletteKind::Folders {
return "folder";
}
if kind == PaletteKind::Workspaces {
return "layout-dashboard";
}
let id = command.id.as_str();
let by_word: [(&str, &str); 24] = [
("calendar", "calendar"),
+25
View File
@@ -577,3 +577,28 @@ fn a_table_gets_a_row_and_a_column_from_the_controls_under_it() {
assert!(text(&d).starts_with("# T\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n"), "{:?}", text(&d));
assert!(text(&d).ends_with("after\n"));
}
#[test]
fn a_heading_folds_its_section_in_the_source_view_from_the_margin_chevron() {
let text = "# One\nintro\n## Two\nbody two\nmore two\n## Three\nbody three\n";
let mut d = driver(&[("f.md", text)]);
d.app.open_note("f.md");
d.settle();
d.app.editing.set_view("f.md", notescraft_ui_egui::center::ViewMode::Markdown);
let buffer_height = |texts: &[Painted]| texts.iter().find(|p| p.text == text).map(|p| p.galley.size().y);
let before = d.settle();
let tall = buffer_height(&before).expect("the source buffer is painted");
let two = d.app.editing.tabs.get("f.md").and_then(|tab| tab.editor.text.find("## Two")).expect("heading");
let row = before.iter().find(|p| p.text == text).expect("buffer");
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
d.click(Pos2::new(row.pos.x - 18.0, at.y));
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.contains("## Two")), "the heading is folded");
let short = buffer_height(&d.settle()).expect("painted");
assert!(short < tall - 20.0, "the section's lines take no room: {tall} then {short}");
let again = d.settle();
let row = again.iter().find(|p| p.text == text).expect("buffer");
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
d.click(Pos2::new(row.pos.x - 18.0, at.y));
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.is_empty()), "a second click unfolds it");
assert_eq!(d.app.editing.tabs.get("f.md").map(|tab| tab.editor.text.as_str()), Some(text), "folding never changes the text");
}
+13 -1
View File
@@ -50,5 +50,17 @@ Confirmed missing by grep: no `query` fence handling, no RTL handling. Pop-out a
Done when: one test per item
- [x] 29. (DONE both: reading mode `view.reading_mode` (View menu; per-tab flag `TabEdit.reading`, test `reading_mode_shows_the_note_but_a_click_and_typing_do_not_change_it`); INDENT: settings `editor.indent_with_tabs` and `editor.indent_width` (1-8), Settings > Editor controls, `keys::Indent`, tests in `editor/tests/keys.rs` and `editor_settings.rs`; open: reading-only open) Reading-only open: "Open in reading mode" (no editing) and an indent-with-tab-or-spaces setting
Done when: a driven test shows a reading tab ignoring a click-to-edit; a unit test for the indent setting
- [ ] 30. (DONE: "Move to…" in the Explorer menu opens a folder picker (palette kind `Folders`, the vault root included) and moves a note or folder, refusing a folder into itself, test `move_to_opens_the_folder_picker_and_moves_a_file_or_folder_but_never_into_itself`. STRUCK: spellcheck language (the checker is the vault's own vocabulary, it has no language). DONE too: table helpers ("+ row", "− row", "+ column", "− column" under an editable table, `Table::add_row` etc. in `blocks/table/edit.rs`, driven test `a_table_gets_a_row_and_a_column_from_the_controls_under_it`); user themes (`<settings folder>/themes/*.toml`, `Session::user_themes`/`theme_named`, listed in Settings and pickable, tests in `engine/tests/user_themes.rs`). OPEN: heading folding in Source mode (the Source view is an egui text edit; hiding lines needs a custom layouter and caret mapping), graph timelapse (needs creation times in the graph data and a play control)) Smaller rows from the audit: heading folding in Source mode, table editing helpers, "Move file to…" folder picker, graph timelapse, user themes in Settings, spellcheck language
- [x] 30. (ALL BUILT; heading folding in Source mode: a chevron in the left margin beside each heading that has a section; a folded section is laid out at 0.1 pt and transparent, so the text stays one buffer and edits, carets and selections keep working; `editor/src/fold.rs`, `highlight::layout_job_folded`, `views/center/source_fold.rs`, tests `fold::tests`, `hidden_ranges_keep_every_byte_in_the_job_and_shrink_only_those_bytes`, driven `a_heading_folds_its_section_in_the_source_view_from_the_margin_chevron`; folds are per open tab, keyed by the heading line, and not saved; DONE: "Move to…" in the Explorer menu opens a folder picker (palette kind `Folders`, the vault root included) and moves a note or folder, refusing a folder into itself, test `move_to_opens_the_folder_picker_and_moves_a_file_or_folder_but_never_into_itself`. STRUCK: spellcheck language (the checker is the vault's own vocabulary, it has no language). DONE too: table helpers ("+ row", "− row", "+ column", "− column" under an editable table, `Table::add_row` etc. in `blocks/table/edit.rs`, driven test `a_table_gets_a_row_and_a_column_from_the_controls_under_it`); user themes (`<settings folder>/themes/*.toml`, `Session::user_themes`/`theme_named`, listed in Settings and pickable, tests in `engine/tests/user_themes.rs`). DONE too: graph timelapse (a Timelapse toggle in the graph card; notes appear by file creation time over a 12 s run, with play/pause, the date reached and a slider; `graph_view/timelapse.rs`, tests there and `every_note_has_a_creation_time`; undated notes always show). - see above) Smaller rows from the audit: heading folding in Source mode, table editing helpers, "Move file to…" folder picker, graph timelapse, user themes in Settings, spellcheck language
Done when: each checked against the code first; built or struck out with the reason
## Round 4: basic Obsidian features still missing after round 3 (checked 2026-10-19 against the code; none built)
- [ ] 31. Note history (Obsidian's File recovery): per-note snapshots or git history shown as a list with a diff and Restore. `git.log`/`git.show` exist in the engine; there is no per-note history view and no snapshots for vaults without git.
Done when: a driven test lists the versions of a note, shows the diff to the current text and restores one as one undoable edit
- [x] 32. (DONE: `view.save_workspace` (name prompt) and `view.load_workspace` (picker, palette kind `Workspaces`) in the View menu; a layout is the tabs in order, the active one, pins, panel widths, panel visibility and the sidebar tab; engine `workspace.list/save/load/delete`, files in `.notescraft/workspaces/`, tests `engine/tests/workspaces.rs` and `a_saved_workspace_brings_the_tabs_and_the_active_one_back_and_skips_notes_that_are_gone`; not saved: the split pane and per-note view mode; no delete control in the UI, only the command) Workspaces: save and load the layout (open tabs, split, sidebar tab, panel widths) by name.
Done when: a test saves a layout, changes it and loads it back
- [x] 33. (DONE: commands `editor.copy_block_link` (adds the `^id` once) and `editor.copy_heading_link`, and "Copy link to this heading" in the Outline row's context menu; `blocks::ensure_block_id`, `FilesConfig::link_text_to` (wiki or Markdown links), tests in `blocks/src/ids.rs` and `copying_a_block_link_adds_the_id_once_and_copying_a_heading_link_needs_no_edit`) Copy a heading or block link from the editor (`[[Note#Heading]]`, `[[Note#^id]]`) and a command to add a block id.
Done when: a test produces the link text for a heading and for a block and adds a missing `^id` once
- [ ] 34. Live Preview editing in the Visual view for inline syntax (the Visual view opens a whole block as source on click; Obsidian edits the rendered line in place).
Done when: decided with the owner first (large change to `note_view`)
- [ ] 35. Hand checks that need a real window: task 24 plus the Linux and Windows items in ROADMAP "Linux and Windows feedback".