diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0ad939..18a2482 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,8 @@ jobs: - platform: macos-latest # universal (Apple Silicon + Intel) args: "--target universal-apple-darwin" - platform: ubuntu-22.04 # AppImage - args: "" + # Adds the bundled emoji font to the AppImage only (issue #45). + args: "--config src-tauri/tauri.appimage.conf.json" - platform: windows-latest # NSIS .exe args: "" runs-on: ${{ matrix.platform }} @@ -94,6 +95,10 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile + - name: Fetch the AppImage's emoji font (Linux only) + if: matrix.platform == 'ubuntu-22.04' + run: ./scripts/fetch-emoji-font.sh + - name: Link clang's runtime for ggml's Metal @available checks (macOS only) if: matrix.platform == 'macos-latest' # rustc links with -nodefaultlibs, so libclang_rt.osx.a (which provides @@ -158,6 +163,12 @@ jobs: deb=$(ls src-tauri/target/release/bundle/deb/*.deb) dpkg-deb -f "$deb" Depends | tee /dev/stderr | grep -q libopenblas0 dpkg-deb --fsys-tarfile "$deb" | tar -xO --wildcards '*usr/share/applications/*.desktop' | tee /dev/stderr | grep -q '^MimeType=text/markdown' + # The AppImage must carry the bitmap emoji font its WebKit falls back + # to once colour fonts are hidden (issue #45). + appimage=$(ls src-tauri/target/release/bundle/appimage/*.AppImage) + (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract 'usr/share/sarala/fonts/*' > /dev/null) + test -s "$RUNNER_TEMP/squashfs-root/usr/share/sarala/fonts/NotoColorEmoji.ttf" \ + || { echo "::error::the AppImage is missing its bundled emoji font"; exit 1; } # The updater endpoint in tauri.conf.json points at a gist, so mirror the # latest.json that tauri-action attached to the release into that gist. Runs diff --git a/.gitignore b/.gitignore index 9e51fb7..cd87351 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ web/wasm/target/ web/public/pandoc/ web/public/ort/ web/public/licenses/ + +# AppImage emoji font, fetched by scripts/fetch-emoji-font.sh +src-tauri/linux/fonts/ diff --git a/scripts/fetch-emoji-font.sh b/scripts/fetch-emoji-font.sh new file mode 100755 index 0000000..a93aa3c --- /dev/null +++ b/scripts/fetch-emoji-font.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# Fetch the bitmap (CBDT) Noto Color Emoji that the Linux AppImage bundles +# (issue #45; see src-tauri/src/appimage_fonts.rs for why). Pinned to a release +# tag and checked by SHA-256. Used by src-tauri/tauri.appimage.conf.json, which +# only the AppImage build passes, so other builds never need this. +# +set -euo pipefail + +TAG="v2.051" +BASE="https://raw.githubusercontent.com/googlefonts/noto-emoji/$TAG/fonts" +DEST="$(cd "$(dirname "$0")/.." && pwd)/src-tauri/linux/fonts" +mkdir -p "$DEST" + +fetch() { # + local out="$DEST/$2" + if [ -f "$out" ] && echo "$3 $out" | shasum -a 256 -c - > /dev/null 2>&1; then + return + fi + curl -sSfL --retry 3 -o "$out.part" "$BASE/$1" + echo "$3 $out.part" | shasum -a 256 -c - > /dev/null \ + || { echo "checksum mismatch for $1" >&2; rm -f "$out.part"; exit 1; } + mv "$out.part" "$out" +} + +fetch NotoColorEmoji.ttf NotoColorEmoji.ttf 72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b +fetch LICENSE NotoColorEmoji-OFL.txt 6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2 +echo "emoji font ready in $DEST" diff --git a/src-tauri/src/appimage_fonts.rs b/src-tauri/src/appimage_fonts.rs new file mode 100644 index 0000000..97c5544 --- /dev/null +++ b/src-tauri/src/appimage_fonts.rs @@ -0,0 +1,142 @@ +//! Keep COLRv1 colour fonts away from the AppImage's bundled WebKit (issue #45). +//! +//! The AppImage ships its own WebKitGTK, and the Skia inside it was built with +//! libstdc++ assertions on. Its COLRv1 code indexes a colour-stop vector out of +//! bounds when it paints a glyph that uses a gradient (for example U+1F50D in +//! Fedora's COLRv1 Noto Color Emoji), the assertion aborts the web process, and +//! the window freezes while the main process lives on. Native packages, the +//! Flatpak and the snap use the system WebKit and are not affected. +//! +//! The fix, applied at the bundle instead of asking users to change fonts: hide +//! every colour font from fontconfig and allow back only the bitmap (CBDT) Noto +//! Color Emoji the AppImage carries, which never reaches that Skia path. +//! +//! How it works: fontconfig reads the file named by `FONTCONFIG_FILE`. We write a +//! small config that includes the host's normal one, adds our font folder, and +//! adds a `` for colour fonts with an `` for ours +//! (fontconfig lets an accepted pattern override a rejected one; a `` +//! accept would not, so ours matches on `file`). The variable is set before +//! Tauri starts, so the WebKit web processes, which are child processes, inherit +//! it. + +use std::path::{Path, PathBuf}; + +/// Where the AppImage bundle puts the emoji font (see `tauri.appimage.conf.json`). +const BUNDLED_FONT: &str = "usr/share/sarala/fonts/NotoColorEmoji.ttf"; + +/// Run first thing in `main`, before any thread exists: `set_var` is only safe +/// while the process is single-threaded. +pub fn apply() { + // Both are exported by the AppImage runtime; outside an AppImage, do nothing. + if std::env::var_os("APPIMAGE").is_none() { + return; + } + let Some(appdir) = std::env::var_os("APPDIR").map(PathBuf::from) else { + return; + }; + let font = appdir.join(BUNDLED_FONT); + let font = font.is_file().then_some(font); + // Respect a config the user already points fontconfig at. + let base = std::env::var_os("FONTCONFIG_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/etc/fonts/fonts.conf")); + + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|d| d.is_dir()) + .unwrap_or_else(std::env::temp_dir); + remove_stale_configs(&dir); + // One file per process: the AppImage mounts at a new path on every launch, + // so two running copies can't share a config. + let path = dir.join(format!("sarala-fonts-{}.conf", std::process::id())); + if std::fs::write(&path, config(&base, font.as_deref())).is_ok() { + std::env::set_var("FONTCONFIG_FILE", &path); + } +} + +/// The fontconfig document. No DOCTYPE on purpose: fontconfig silently ignores a +/// config whose `fonts.dtd` it can't resolve, and this file lives in a temp dir. +fn config(base: &Path, font: Option<&Path>) -> String { + let mut out = String::from("\n\n"); + out += &format!( + " {}\n", + xml_escape(&base.to_string_lossy()) + ); + if let Some(font) = font { + if let Some(dir) = font.parent() { + out += &format!(" {}\n", xml_escape(&dir.to_string_lossy())); + } + // Prefer it for emoji even if the host config ranks another family first. + out += " \n emoji\n Noto Color Emoji\n \n"; + } + out += " \n"; + if let Some(font) = font { + out += &format!( + " {}\n", + xml_escape(&font.to_string_lossy()) + ); + } + out += " true\n"; + out += " \n\n"; + out +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Delete configs left by earlier runs whose process is gone (a crash or a +/// killed process never cleans up after itself). +fn remove_stale_configs(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name + .to_str() + .and_then(|n| n.strip_prefix("sarala-fonts-")) + .and_then(|n| n.strip_suffix(".conf")) + else { + continue; + }; + if pid.parse::().is_ok() && !Path::new("/proc").join(pid).exists() { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_rejects_colour_fonts_and_accepts_the_bundled_one() { + let font = Path::new("/tmp/.mount_Sarala1/usr/share/sarala/fonts/NotoColorEmoji.ttf"); + let conf = config(Path::new("/etc/fonts/fonts.conf"), Some(font)); + assert!(conf.contains("/etc/fonts/fonts.conf")); + assert!(conf.contains("/tmp/.mount_Sarala1/usr/share/sarala/fonts")); + assert!(conf.contains(&format!( + "{}", + font.display() + ))); + assert!(conf.contains("true")); + assert!(!conf.contains("DOCTYPE")); + } + + #[test] + fn config_without_bundled_font_still_rejects_colour_fonts() { + let conf = config(Path::new("/etc/fonts/fonts.conf"), None); + assert!(!conf.contains("")); + assert!(!conf.contains("")); + assert!(conf.contains("")); + } + + #[test] + fn paths_are_xml_escaped() { + let conf = config(Path::new("/home/a&b/.conf"), None); + assert!(conf.contains("/home/a&b/<fonts>.conf")); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f014e9e..651d889 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod ai; +// Pure std, so its tests run on every OS; only Linux calls it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod appimage_fonts; mod file_open; mod menu; mod voice; @@ -610,7 +613,39 @@ fn new_window(app: AppHandle) -> Result<(), String> { .map_err(|e| e.to_string()) } +/// File name that switches on portable settings (issue #40): when a file with +/// this name sits next to the app, settings are read from and written to it +/// instead of the per-user config folder. +const PORTABLE_SETTINGS: &str = "sarala-settings.json"; + +/// The folder a user thinks of as "where Sarala is": next to `Sarala.exe` on +/// Windows, next to the `.AppImage` file (not its temporary mount), and next to +/// `Sarala.app` on macOS (not `Sarala.app/Contents/MacOS`). +fn app_location() -> Option { + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return PathBuf::from(appimage).parent().map(Path::to_path_buf); + } + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + if cfg!(target_os = "macos") { + if let Some(bundle) = dir + .ancestors() + .find(|a| a.extension().is_some_and(|e| e == "app")) + { + return bundle.parent().map(Path::to_path_buf); + } + } + Some(dir.to_path_buf()) +} + +/// `/sarala-settings.json` if it exists, else +/// `/settings.json`. fn settings_path(app: &AppHandle) -> Result { + if let Some(portable) = app_location().map(|d| d.join(PORTABLE_SETTINGS)) { + if portable.is_file() { + return Ok(portable); + } + } let dir = app.path().app_config_dir().map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir.join("settings.json")) @@ -620,6 +655,8 @@ fn settings_path(app: &AppHandle) -> Result { fn load_settings(app: AppHandle) -> Result { let path = settings_path(&app)?; match fs::read_to_string(&path) { + // A freshly created, empty portable file means "start from defaults". + Ok(text) if text.trim().is_empty() => Ok(serde_json::json!({})), Ok(text) => serde_json::from_str(&text).map_err(|e| e.to_string()), Err(_) => Ok(serde_json::json!({})), } @@ -629,7 +666,10 @@ fn load_settings(app: AppHandle) -> Result { fn save_settings(app: AppHandle, value: serde_json::Value) -> Result<(), String> { let path = settings_path(&app)?; let text = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?; - fs::write(&path, text).map_err(|e| e.to_string()) + // Temp file + rename, so a crash mid-write never leaves a truncated file. + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, text).map_err(|e| e.to_string())?; + fs::rename(&tmp, &path).map_err(|e| e.to_string()) } #[tauri::command] @@ -1318,6 +1358,10 @@ fn apply_gnome_button_layout() { } fn main() { + // Before anything starts a thread (see the module for why). + #[cfg(target_os = "linux")] + appimage_fonts::apply(); + tauri::Builder::default() .manage({ #[cfg(target_os = "macos")] diff --git a/src-tauri/tauri.appimage.conf.json b/src-tauri/tauri.appimage.conf.json new file mode 100644 index 0000000..1bdfa4c --- /dev/null +++ b/src-tauri/tauri.appimage.conf.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "linux": { + "appimage": { + "files": { + "/usr/share/sarala/fonts/NotoColorEmoji.ttf": "linux/fonts/NotoColorEmoji.ttf", + "/usr/share/sarala/fonts/NotoColorEmoji-OFL.txt": "linux/fonts/NotoColorEmoji-OFL.txt" + } + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index e318d2f..9bfb237 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,12 +50,15 @@ import { onExternalChange, onTreeChange, onTreeTouch, } from "./platform"; import { - executeCommand, openFile, openFolder, insertImageFromPath, + executeCommand, openFile, openFolder, insertImageFromPath, confirmCloseDirty, } from "./commands"; import { BLOCK_TARGETED_IDS } from "./menudata"; import { makeMenuKeyHandler } from "./shortcuts"; import { showSource } from "./viewswitch"; -import { startAutosave, findRecoverable, restoreSession, shadowBaseName, discardShadows, discardShadowFor } from "./autosave"; +import { startAutosave, findRecoverable, restoreSession, shadowBaseName, declineShadows, discardShadowFor } from "./autosave"; + +/** sessionStorage flag: the startup recovery prompt has run in this session. */ +const RECOVERY_ASKED = "sarala.recoveryAsked"; export default function App() { let editorEl: HTMLDivElement | undefined; @@ -196,10 +199,10 @@ export default function App() { const dirty = openTabs().filter((tab) => tab.dirty); if (!dirty.length) return; event.preventDefault(); - if (await confirmDialog(`Close the window and discard unsaved changes to ${dirty.map((tab) => tab.name).join(", ")}?`)) { - // Deliberately discarding — drop the autosave shadow so the next - // launch doesn't offer to "recover" these changes. (A crash skips - // this handler, so genuine crash recovery still works.) + if (await confirmCloseDirty(dirty.map((tab) => tab.id), "window")) { + // Saved or deliberately discarded: drop the autosave shadow so the + // next launch doesn't offer to "recover" these changes. (A crash + // skips this handler, so genuine crash recovery still works.) for (const tab of dirty) removeTab(tab.id); await Promise.all(dirty.map((tab) => discardShadowFor(tab.filePath))); await win.destroy(); @@ -209,7 +212,15 @@ export default function App() { // Crash recovery runs once, in the initial window (new windows carry a // "main-" label), so user-opened windows don't re-prompt. if (win.label === "main") { - const cands = await findRecoverable(); + // Once per app session, not once per page load: a reload of the + // webview must not open a second prompt over the first (the first, + // orphaned by the reload, then ignores its buttons). + let asked = false; + try { + asked = sessionStorage.getItem(RECOVERY_ASKED) === "1"; + sessionStorage.setItem(RECOVERY_ASKED, "1"); + } catch { /* storage unavailable: ask, as before */ } + const cands = asked ? [] : await findRecoverable(); if (cands.length) { const newest = cands[0]; const extra = cands.length > 1 @@ -218,10 +229,11 @@ export default function App() { const ok = await confirmDialog( `Unsaved changes from a previous session were found for ${shadowBaseName(newest.path)}${extra}. Restore them now?`, ); - // Restore the newest and keep the rest for per-file recovery; on - // decline, discard every candidate so this never re-prompts. + // Restore the newest and keep the rest for per-file recovery. On + // decline, keep them all but marked declined, so this prompt + // doesn't return while opening the file still offers recovery. if (ok) await restoreSession(newest); - else await discardShadows(cands); + else await declineShadows(cands); } // Once per launch (main window only, after any recovery prompt), diff --git a/src/autosave.ts b/src/autosave.ts index 24d94c0..3d5f901 100644 --- a/src/autosave.ts +++ b/src/autosave.ts @@ -84,13 +84,23 @@ async function tick(): Promise { } finally { ticking = false; } } +/** How long a declined shadow is kept before it is cleaned up. */ +const DECLINED_KEEP_MS = 14 * 24 * 60 * 60 * 1000; + /** Shadows whose content differs from the file currently on disk (i.e. real - * unsaved work from a previous session). Stale shadows that match disk are - * cleaned up as a side effect. Newest first. */ + * unsaved work from a previous session), for the startup prompt. Stale + * shadows that match disk are cleaned up as a side effect, and so are ones + * declined over two weeks ago; more recently declined ones are skipped (the + * prompt already asked) but kept for recovery when their file is opened. + * Newest first. */ export async function findRecoverable(): Promise { const shadows = await listShadows(); const out: ShadowSession[] = []; for (const s of shadows) { + if (s.declinedAt) { + if (Date.now() - s.declinedAt > DECLINED_KEEP_MS) await clearShadow(keyForPath(s.path)); + continue; + } try { const ed = await readFileEncoded(s.path); if (normalizeForCompare(ed.content) !== normalizeForCompare(s.content)) out.push(s); @@ -112,10 +122,13 @@ export async function shadowFor(path: string, diskContent: string): Promise { - for (const s of sessions) await clearShadow(keyForPath(s.path)); +/** The user said "No" to restoring these. Mark them declined instead of + * deleting them: the startup prompt won't ask again, but the work survives a + * wrong click and is offered again when its file is opened. Saving the file + * clears it as usual; otherwise it is cleaned up after two weeks. */ +export async function declineShadows(sessions: ShadowSession[]): Promise { + const now = Date.now(); + for (const s of sessions) await writeShadow(keyForPath(s.path), { ...s, declinedAt: s.declinedAt ?? now }); } /** Drop the shadow for one path. Called on a *deliberate* close/discard so the diff --git a/src/commands.ts b/src/commands.ts index 02ba4a7..b44ae11 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,7 +8,7 @@ import { targetBlockIndex, requestCaret, requestSelection, setActive, undo, redo, setCaretProvider, spellcheckOn, setSpellcheckOn, smartPunctuation, setSmartPunctuation, preserveBreaks, setPreserveBreaks, lineEnding, setLineEnding, - finalNewline, setFinalNewline, setAutosaveInterval, + finalNewline, setFinalNewline, setAutosaveInterval, closePrompt, setEncodingLossy, setExternalChange, setDocDirty, copyImageToAssets, setCopyImageToAssets, copyImagesToFolder, tableFullWidth, setTableFullWidth, mathAltDelimiters, setMathAltDelimitersSig, mathFence, setMathFenceSig, @@ -19,7 +19,7 @@ import { alwaysOnTop, setAlwaysOnTop, zoom, setZoom, clampZoom, bumpRenderEpoch, proseFont, monoFont, markMissing, } from "./store"; -import { selectAllDocument } from "./blockselect"; +import { selectAllDocument, selectedSourceRange } from "./blockselect"; import { toggleMark, type MarkKind } from "./inlineformat"; import { linkAt, linkDestination, removeLink, type LinkTarget } from "./links"; import { applyBlockKind, type BlockKind } from "./blocktype"; @@ -27,8 +27,8 @@ import { fontEmbedCss } from "./fonts"; import { isTauri, isWeb, web, pickFolder, pickMarkdownFile, pickSavePath, pickExportPath, pickImportFile, readFileEncoded, reopenWithEncoding, writeTextFile, type EncodedDoc, - watchFile, clearShadow, watchTree, openExternal, - confirmDialog, alertDialog, renameFile, deleteFile, openNewWindow, + watchFile, watchTree, openExternal, + confirmDialog, askSaveDialog, alertDialog, renameFile, deleteFile, openNewWindow, pandocImport, pandocExport, exportPdf, runCommand, revealInDir, clipboardWriteText, clipboardReadText, pathExists, pickImageFile, copyAsset, @@ -46,7 +46,7 @@ import { import { renderMermaidIn } from "./mermaid"; import { renderD2In } from "./d2"; import { setLiveHighlight, setLiveSubSup } from "./livesource"; -import { shadowFor, restoreSession, keyForPath, discardShadowFor } from "./autosave"; +import { shadowFor, restoreSession, discardShadowFor, declineShadows } from "./autosave"; import { stripControlChars } from "./richpaste"; import { recentFiles, addRecentFile, clearRecentFiles, removeRecentFile, pinnedFiles, @@ -180,8 +180,7 @@ export async function closeTab(id = activeTabId()): Promise { if (!document) return; closingTabs.add(id); try { - const name = document.filePath ? fileName0(document.filePath) : "Untitled.md"; - if (document.dirty && !(await confirmDialog(`Close ${name} without saving? Unsaved changes will be lost.`))) return; + if (document.dirty && !(await confirmCloseDirty([id], "tab"))) return; removeTab(id); await discardShadowFor(document.filePath); } finally { closingTabs.delete(id); } @@ -242,7 +241,8 @@ export async function openFile(path?: string) { ))) { await restoreSession(shadow); } else { - if (shadow) await clearShadow(keyForPath(p)); + // Kept, not deleted: a wrong click must not lose the work (see declineShadows). + if (shadow) await declineShadows([shadow]); await applyOpened(p, ed); } await addRecentFile(p); @@ -289,18 +289,19 @@ function textForDisk(text = fullText()): string { return lineEnding() === "crlf" ? text.replace(/\n/g, "\r\n") : text; } -async function saveTab(as = false) { - const id = activeTabId(); +/** Save one tab (the active one by default). Resolves true once it is on disk, + * false when the user backed out of the Save dialog or the save was refused. */ +async function saveTab(as = false, id = activeTabId()): Promise { const initial = getTabDocument(id)!; const name = initial.filePath ? fileName0(initial.filePath) : "Untitled.md"; const path = as || !initial.filePath ? await pickSavePath(name) : initial.filePath; - if (!path && (isTauri || isWeb)) return; + if (!path && (isTauri || isWeb)) return false; const document = getTabDocument(id); - if (!document) return; + if (!document) return false; const duplicate = path ? findTabByPath(path) : undefined; if (duplicate !== undefined && duplicate !== id) { await alertDialog("That file is already open in another tab. Choose a different name or save from that tab."); - return; + return false; } const text = joinBlocks(document.blocks.map((b) => b.text)); await writeTextFile(path ?? name, textForDisk(text), document.encoding, document.hadBom); @@ -311,13 +312,40 @@ async function saveTab(as = false) { await addRecentFile(path); if (as) await refreshTree(); } + return true; } export async function save() { await saveTab(); } + +/** + * Ask what to do with unsaved tabs before closing them (one tab, or a whole + * window). Resolves true when closing may go ahead: the user chose to discard, + * or chose Save and every tab was saved. The wording follows Settings ▸ Files ▸ + * "When closing unsaved" (issue #40). + */ +export async function confirmCloseDirty(ids: number[], scope: "tab" | "window"): Promise { + const docs = ids.map((id) => ({ id, doc: getTabDocument(id) })).filter((d) => d.doc?.dirty); + if (!docs.length) return true; + const names = docs.map(({ doc }) => (doc!.filePath ? fileName0(doc!.filePath) : "Untitled.md")).join(", "); + if (closePrompt() === "discard") { + return await confirmDialog(scope === "tab" + ? `Close ${names} without saving? Unsaved changes will be lost.` + : `Close the window and discard unsaved changes to ${names}?`); + } + const choice = await askSaveDialog(`Save changes to ${names} before closing?`); + if (choice === "cancel") return false; + if (choice === "discard") return true; + for (const { id } of docs) { + // Show the tab being saved, so a Save As dialog for an untitled one has context. + if (scope === "window") switchTab(id); + if (!(await saveTab(false, id))) return false; + } + return true; +} async function saveAs() { await saveTab(true); } async function renameCurrent() { const from = doc.filePath; - if (!from && isWeb) return saveTab(); + if (!from && isWeb) { await saveTab(); return; } if (!from) { await alertDialog("Save the document before renaming it."); return; @@ -775,8 +803,35 @@ function insertFrontMatter() { // ---------- Edit ---------- +/** The Markdown source of the current selection, or null when nothing is + * selected. Works for a selection inside the block being edited, inside a + * rendered block, or across several blocks. */ +function selectedMarkdown(): string | null { + const range = selectedSourceRange(); + if (range) { + const { start, end } = range; + const parts: string[] = []; + for (let i = start.block; i <= end.block; i++) { + const text = doc.blocks[i]?.text ?? ""; + const from = i === start.block ? start.offset : 0; + const to = i === end.block ? end.offset : text.length; + parts.push(text.slice(from, to)); + } + const md = joinBlocks(parts); + return md.trim() ? md : null; + } + // A selection inside the block being edited: its source is the text itself. + const sel = blockApi?.selectionOffsets(); + if (sel && sel.end > sel.start && doc.activeIndex >= 0) { + return doc.blocks[doc.activeIndex].text.slice(sel.start, sel.end); + } + return null; +} + +/** Copy as Markdown: the selection if there is one, else the block being + * edited, else the whole document. */ async function copyAsMarkdown() { - const text = doc.activeIndex >= 0 ? doc.blocks[doc.activeIndex].text : fullText(); + const text = selectedMarkdown() ?? (doc.activeIndex >= 0 ? doc.blocks[doc.activeIndex].text : fullText()); await clipboardWriteText(text); } @@ -1238,7 +1293,7 @@ const registry: Record = { "edit.undo": () => (focusedTextField()?.value ? void document.execCommand("undo") : undo()), "edit.redo": () => (focusedTextField()?.value ? void document.execCommand("redo") : redo()), "edit.copy_markdown": copyAsMarkdown, - "edit.copy_html": async () => clipboardWriteText(await renderBody(fullText())), + "edit.copy_html": async () => clipboardWriteText(await renderBody(selectedMarkdown() ?? fullText())), "edit.copy_plain": copyPlain, "edit.paste": pasteText, "edit.paste_plain": pastePlain, diff --git a/src/components/Block.tsx b/src/components/Block.tsx index 4d73fa0..bf91fb5 100644 --- a/src/components/Block.tsx +++ b/src/components/Block.tsx @@ -80,7 +80,7 @@ function renumberBelow(rest: string, indent: string, n: number): string { const lines = rest.split("\n"); for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (line.startsWith(indent + " ") || line.startsWith(indent + "\t")) continue; + if (line.trim() === "" || line.startsWith(indent + " ") || line.startsWith(indent + "\t")) continue; const m = line.match(/^(\s*)(\d+)([.)]\s.*)$/); if (!m || m[1] !== indent || Number(m[2]) !== n) break; n++; @@ -89,6 +89,80 @@ function renumberBelow(rest: string, indent: string, n: number): string { return lines.join("\n"); } +const LIST_MARKER = /^(\s*)([-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/; + +/** Columns of leading whitespace (a tab counts as four). */ +function indentWidth(line: string): number { + return line.match(/^[ \t]*/)![0].replace(/\t/g, " ").length; +} + +/** The marker for the item after one whose marker is `marker`: the next number, + * an unchecked box. When the number gains a digit, one space less, so the + * item's content stays at the same column. */ +function nextMarker(marker: string): { marker: string; num: RegExpMatchArray | null } { + const num = marker.match(/^(\s*)(\d+)([.)])(\s+)$/); + let next = marker; + if (num) { + const n = String(Number(num[2]) + 1); + const spaces = n.length > num[2].length && num[4].length > 1 ? num[4].slice(1) : num[4]; + next = `${num[1]}${n}${num[3]}${spaces}`; + } + if (/\[[xX]\]/.test(next)) next = next.replace(/\[[xX]\]/, "[ ]"); + return { marker: next, num }; +} + +/** + * Where the list item whose marker line ends at `lineEnd` really ends: past + * any following lines indented to its content (`contentIndent` columns) and + * the blank lines between them, but not trailing blank lines. Returns + * `lineEnd` when the item is that one line. + */ +export function listItemEnd(t: string, lineEnd: number, contentIndent: number): number { + let end = lineEnd; + let pos = lineEnd; + while (pos < t.length) { + const next = t.indexOf("\n", pos + 1); + const lineStop = next === -1 ? t.length : next; + const line = t.slice(pos + 1, lineStop); + if (line.trim() !== "") { + if (indentWidth(line) < contentIndent) break; + end = lineStop; + } + pos = lineStop; + } + return end; +} + +/** Number the items at `indent` in `rest` (after its first line) n, n+1, …, + * through the end of their list. */ +function renumberFrom(rest: string, indent: string, n: number): string { + const lines = rest.split("\n"); + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === "" || line.startsWith(indent + " ") || line.startsWith(indent + "\t")) continue; + const m = line.match(/^(\s*)(\d+)([.)]\s.*)$/); + if (!m || m[1] !== indent) break; + lines[i] = `${indent}${n++}${m[3]}`; + } + return lines.join("\n"); +} + +/** + * `rest` is the text after a new, empty list item (it starts with "\n"). When + * the lines after it are that item's content (indented to `contentIndent`), + * drop the blank lines in between: an item that starts with a blank line is + * empty in Markdown, and its indented content then reads as a code block. + */ +export function attachToNewItem(rest: string, contentIndent: number): string { + const lines = rest.split("\n"); + let i = 1; + while (i < lines.length && lines[i].trim() === "") i++; + if (i > 1 && i < lines.length && indentWidth(lines[i]) >= contentIndent) { + return ["", ...lines.slice(i)].join("\n"); + } + return rest; +} + export default function Block(props: Props) { let el: HTMLDivElement | undefined; let rootEl: HTMLDivElement | undefined; @@ -410,8 +484,32 @@ export default function Block(props: Props) { } // List / quote continuation. const cont = line.match(/^(\s*)([-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+|(?:>\s*)+)/); + const sel = getSelectionOffsets(el!); + const atLineEnd = sel.start === sel.end && offset >= end; + // Put a new item `marker` at [from, to) and the rest of the block after it, + // renumbering the items below. + const newItem = (from: string, marker: string, num: RegExpMatchArray | null, rest: string) => { + const head = from + marker; + const after = atLineEnd && !marker.includes(">") + ? attachToNewItem(rest, marker.replace(/\t/g, " ").length) + : rest; + commit(head + (num ? renumberBelow(after, num[1], Number(num[2]) + 1) : after), head.length); + }; if (cont) { const body = line.slice(cont[0].length); + if (body.trim() === "" && offset >= end && start > 0 && !cont[0].includes(">")) { + // An empty item that still holds content below it (Enter, then Enter + // again): drop the marker and give the content back to the item + // above. Splitting would leave it as an indented code block. + const next = t.slice(end).split("\n").slice(1).find((l) => l.trim() !== ""); + if (next !== undefined && indentWidth(next) >= cont[0].replace(/\t/g, " ").length) { + const head = t.slice(0, start - 1); + const num = cont[0].match(/^(\s*)(\d+)[.)]/); + const rest = t.slice(end); + commit(head + (num ? renumberFrom(rest, num[1], Number(num[2])) : rest), head.length); + return; + } + } if (body.trim() === "" && offset >= end) { // Empty item ends the list: drop the marker line and split. const before = t.slice(0, start).replace(/\n$/, ""); @@ -419,20 +517,64 @@ export default function Block(props: Props) { props.onSplit(before, after); return; } - let marker = cont[0]; - const num = marker.match(/^(\s*)(\d+)([.)])(\s+)$/); - if (num) marker = `${num[1]}${Number(num[2]) + 1}${num[3]}${num[4]}`; - if (marker.match(/\[[xX]\]/)) marker = marker.replace(/\[[xX]\]/, "[ ]"); - if (num) { - // A new item mid-list: shift the numbers below it along. - const sel = getSelectionOffsets(el!); - const head = t.slice(0, sel.start) + "\n" + marker; - commit(head + renumberBelow(t.slice(sel.end), num[1], Number(num[2]) + 1), head.length); + // The item goes on below this line (an indented heading or paragraph): + // a new empty line inside the item, right here. A new item can't go + // here without taking that content (its heading would then sit on the + // number's line), and putting it after the content moved the caret + // away. Enter on the empty line adds the next item (below). + const contentIndent = cont[0].replace(/\t/g, " ").length; + if (atLineEnd && !cont[0].includes(">") && listItemEnd(t, end, contentIndent) > end) { + const pad = " ".repeat(contentIndent); + insertAtCaret("\n" + pad + "\n" + pad); return; } - insertAtCaret("\n" + marker); + // A new item right below; text after the caret goes with it. + const { marker, num } = nextMarker(cont[0]); + newItem(t.slice(0, sel.start) + "\n", marker, num, t.slice(sel.end)); return; } + // An indented line inside a list item (its heading or a later paragraph). + // Splitting the block here cut the list in two, and the indented text left + // at the top of the new block read as a code block. Stay in the item. + const lead = line.match(/^[ \t]+/)?.[0]; + if (lead && LIST_MARKER.test(t)) { + if (line.trim() === "" && atLineEnd) { + // Enter on an empty line in an item: the next item, after this + // item's content. The empty line itself (and the blank line left + // above it) goes, so the item reads as it did before. + const width = indentWidth(line); + const lines = t.slice(0, start).split("\n"); + for (let k = lines.length - 1; k >= 0; k--) { + const m = lines[k].match(LIST_MARKER); + if (m && indentWidth(lines[k]) < width) { + // The run of blank lines this one is in: keep one, so paragraphs + // on either side stay apart; drop the rest (at most two). + const all = t.split("\n"); + const at = lines.length - 1; + let lo = at, hi = at; + while (lo > 0 && all[lo - 1].trim() === "") lo--; + while (hi < all.length - 1 && all[hi + 1].trim() === "") hi++; + const drop = Math.min(2, hi - lo); // run length - 1, capped + // `drop` lines inside the run, as close to this one as fits. + const first = Math.min(Math.max(lo, at - drop + 1), hi - drop + 1); + const u = [...all.slice(0, first), ...all.slice(first + drop)].join("\n"); + const markerEnd = lines.slice(0, k + 1).join("\n").length; + const itemEnd = listItemEnd(u, markerEnd, m[0].replace(/\t/g, " ").length); + const { marker, num } = nextMarker(m[0]); + // A blank line before it, so it can't read as part of the item's + // last paragraph. + const head = u.slice(0, itemEnd) + (itemEnd > markerEnd ? "\n\n" : "\n") + marker; + const rest = u.slice(itemEnd); + commit(head + (num ? renumberBelow(rest, num[1], Number(num[2]) + 1) : rest), head.length); + return; + } + } + } else { + // A new paragraph in the same item, at the same indent. + insertAtCaret("\n" + lead + "\n" + lead); + return; + } + } // Plain paragraph: finalize this block, continue in a fresh one. props.onSplit(t.slice(0, offset), t.slice(offset)); }; @@ -1291,10 +1433,14 @@ export default function Block(props: Props) { onBlur={(e) => { // Keep the block active when focus leaves the page itself (native // menu click, app switch) or moves into this block's own chrome - // (table toolbar inputs), so commands still have a target. + // (table toolbar inputs), so commands still have a target. The + // same for the right-click menu: it takes focus for keyboard use, + // and ending the edit there re-rendered the block, which threw + // away the selection Copy / Cut / Copy as… act on. // A disposed block's activation belongs to the one replacing it. const to = e.relatedTarget as Node | null; - if (!disposed && document.hasFocus() && !(to && rootEl?.contains(to))) props.onDeactivate(); + const intoMenu = to instanceof Element && !!to.closest(".ctx-menu"); + if (!disposed && document.hasFocus() && !intoMenu && !(to && rootEl?.contains(to))) props.onDeactivate(); }} onCompositionStart={() => { composing = true; caretBeforeInput = el ? getCaretOffset(el) : null; }} onCompositionEnd={() => { composing = false; onInput(); }} diff --git a/src/components/EditorContextMenu.tsx b/src/components/EditorContextMenu.tsx index beb9a3d..10433da 100644 --- a/src/components/EditorContextMenu.tsx +++ b/src/components/EditorContextMenu.tsx @@ -45,10 +45,31 @@ interface MenuState { const [state, setState] = createSignal(null); +/** The selection and focus as they were when the menu opened. Menu items act + * on them, not on whatever the selection became once the menu took focus. */ +let opened: { range: Range | null; focus: HTMLElement | null } = { range: null, focus: null }; + +function restoreOpened() { + const { range, focus } = opened; + // Only a real selection that is still on the page. With nothing selected, + // the right-click placed a caret and may have re-rendered the block, so + // leave the caret it set alone (Paste goes there). + if (!range || range.collapsed || !range.commonAncestorContainer.isConnected) return; + if (focus?.isConnected && focus.isContentEditable) focus.focus({ preventScroll: true }); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); +} + /** Open the editor context menu at a screen position, with the current selection. */ export function openEditorMenu(x: number, y: number, selection: string, inTable = false) { // Opened from the keyboard (Shift+F10 / Menu key): no pointer, so use the caret. if (x === 0 && y === 0) ({ x, y } = keyboardMenuPoint()); + const sel = window.getSelection(); + opened = { + range: sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null, + focus: document.activeElement as HTMLElement | null, + }; setState({ x, y, selection, inTable, link: !!linkTargetAtCaret() }); } export function closeEditorMenu() { @@ -76,6 +97,7 @@ export default function EditorContextMenu() { // click — execCommand copy/cut and copyPlain all read the live selection. const act = (fn: () => void) => (e: MouseEvent) => { e.preventDefault(); + restoreOpened(); fn(); closeEditorMenu(); }; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index ce573c1..9628acb 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -20,7 +20,7 @@ import { web } from "../platform"; import { proseFont, setProseFont, monoFont, setMonoFont, spellcheckOn, smartPunctuation, preserveBreaks, lineEnding, finalNewline, - autosaveInterval, copyImageToAssets, copyImagesToFolder, tableFullWidth, + autosaveInterval, closePrompt, setClosePrompt, copyImageToAssets, copyImagesToFolder, tableFullWidth, mathAltDelimiters, mathFence, emojiEnabled, highlightEnabled, subSupEnabled, autolinkEnabled, focusMode, typewriterMode, statusBarVisible, zoom, theme, setCopyImagesToFolder, customScheme, htmlEmbeds, setHtmlEmbedsSig, mathAutoNumber, setMathAutoNumberSig, physicsEnabled, setPhysicsEnabledSig, imageUploadUrl, setImageUploadUrl, bumpRenderEpoch, @@ -268,6 +268,20 @@ const ALL_SECTIONS = (): Section[] => [ ], run: (v) => executeCommand(`edit.autosave.${v}`), }, + { + kind: "select", id: "close_prompt", label: "When closing unsaved", + desc: "What the close prompt asks.", + get: closePrompt, + options: [ + { value: "discard", label: "Close without saving?" }, + { value: "save", label: "Save changes?" }, + ], + run: (v) => { + const value = v === "save" ? "save" : "discard"; + setClosePrompt(value); + void setSetting("closePrompt", value); + }, + }, { advanced: true, kind: "select", id: "line_ending", label: "Line endings", desc: "Used when saving.", diff --git a/src/menuKeyboard.ts b/src/menuKeyboard.ts index f4e5563..59aa4ee 100644 --- a/src/menuKeyboard.ts +++ b/src/menuKeyboard.ts @@ -94,7 +94,19 @@ export function attachMenuKeyboard(menu: HTMLElement, close: () => void, restore }); }; menu.addEventListener("keydown", onKey); - requestAnimationFrame(() => focusIn(menu, 0)); + requestAnimationFrame(() => { + focusIn(menu, 0); + // WebKit drops a selection in rendered (non-editable) text when focus moves + // to a menu item: the highlight vanished and Copy found nothing to copy. + // Put it back, so it stays visible while the menu is open. + if (range && !range.collapsed) { + const s = window.getSelection(); + if (s && (s.rangeCount === 0 || s.isCollapsed)) { + s.removeAllRanges(); + s.addRange(range); + } + } + }); return () => { menu.removeEventListener("keydown", onKey); diff --git a/src/platform.ts b/src/platform.ts index b8621b5..ca0c093 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -255,6 +255,9 @@ export interface ShadowSession { savedAt: number; encoding: string; hadBom: boolean; + /** Set when the user answered "No" to restoring it. Kept, not deleted, so a + * wrong click can't destroy unsaved work: opening the file still offers it. */ + declinedAt?: number; } export async function writeShadow(key: string, data: ShadowSession): Promise { @@ -359,6 +362,25 @@ export async function confirmDialog(message: string, title = "Sarala"): Promise< return await ask(message, { title }); } +/** + * "Save changes?" with Save / Don't Save / Cancel. The browser has no + * three-button dialog, so there OK saves and Cancel keeps the document open + * (never a silent discard). + */ +export async function askSaveDialog(message: string, title = "Sarala"): Promise<"save" | "discard" | "cancel"> { + if (!isTauri) return window.confirm(message) ? "save" : "cancel"; + const { message: msg } = await import("@tauri-apps/plugin-dialog"); + const result = await msg(message, { + title, + kind: "warning", + buttons: { yes: "Save", no: "Don't Save", cancel: "Cancel" }, + }); + // Custom buttons come back as their label; accept the stock names too. + if (result === "Save" || result === "Yes") return "save"; + if (result === "Don't Save" || result === "No") return "discard"; + return "cancel"; +} + /** Native message box; window.alert in the browser. */ export async function alertDialog(message: string, title = "Sarala"): Promise { if (!isTauri) { diff --git a/src/settings.ts b/src/settings.ts index afb1f19..7448087 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,7 +2,7 @@ import { createSignal } from "solid-js"; import { isTauri } from "./platform"; import { setSpellcheckOn, setSmartPunctuation, setPreserveBreaks, setLineEnding, - setFinalNewline, setAutosaveInterval, + setFinalNewline, setAutosaveInterval, setClosePrompt, setCopyImageToAssets, setCopyImagesToFolder, setTableFullWidth, bumpRenderEpoch, setTheme, setZoom, clampZoom, THEMES, type ThemeId, setSidebarWidth, clampSidebar, setStatusBarVisible, @@ -123,6 +123,7 @@ function hydrateStore() { setLineEnding(getSetting<"lf" | "crlf">("lineEnding", "lf")); setFinalNewline(getSetting<"ensure" | "preserve" | "trim">("finalNewline", "ensure")); setAutosaveInterval(getSetting("autosaveInterval", 5)); + setClosePrompt(getSetting<"discard" | "save">("closePrompt", "discard") === "save" ? "save" : "discard"); setCopyImageToAssets(getSetting("copyImageToAssets", false)); setCopyImagesToFolder(getSetting("copyImagesToFolder", "assets")); setTableFullWidth(getSetting("tableFullWidth", false)); diff --git a/src/store.ts b/src/store.ts index 4179812..54cdadd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -161,6 +161,10 @@ export const [finalNewline, setFinalNewline] = createSignal<"ensure" | "preserve" | "trim">("ensure"); // Autosave shadow cadence in seconds; 0 disables. Drives the autosave loop. export const [autosaveInterval, setAutosaveInterval] = createSignal(5); +/** Closing with unsaved changes: "discard" asks "Close without saving?"; "save" + * asks "Save changes?" with Save / Don't Save / Cancel (issue #40). */ +export type ClosePrompt = "discard" | "save"; +export const [closePrompt, setClosePrompt] = createSignal("discard"); // An external change to the open file detected by the Rust watcher: drives the // reload-or-keep conflict banner. `deleted` = the file vanished on disk. diff --git a/src/styles/app.css b/src/styles/app.css index 8724525..f00b8e2 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1064,6 +1064,20 @@ body { font-family: var(--font-ui); } box-sizing: border-box; } .rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.3em; } +/* Numbers likewise: the native marker sat 3 to 5px left of the live view's + number (a right-aligned 1.5em box, .md-olnum), so the numbers jumped when + a list was clicked into. Same box here; counter(list-item) follows start= + and value= like the native marker does. */ +.rendered ol:not(.footnotes) { list-style: none; } +.rendered ol:not(.footnotes) > li:not(:has(> input[type="checkbox"]))::before { + content: counter(list-item) "."; + float: left; + width: 1.5em; + margin-left: -1.5em; + text-align: right; + padding-right: 0.4em; + box-sizing: border-box; +} .rendered input[type="checkbox"] { appearance: none; width: 13px; @@ -1199,8 +1213,12 @@ body { font-family: var(--font-ui); } /* Font (typography) icon — size it so the glyph actually shows next to the name. */ .sb-font svg { width: 15px; height: 15px; flex: none; } .status-right button.on { color: var(--accent); background: var(--accent-soft); } -/* Right-side status buttons keep their intrinsic width (don't shrink/clip). */ -.status-right > button { flex: none; } +/* Right-side status items keep their intrinsic width (don't shrink/clip). + The readouts too: "Markdown · UTF-8" used to shrink when the caret's + "Ln, Col" grew, wrap onto two lines and make the whole bar taller. */ +.status-right > button, +.status-right > .sb-saved, +.status-right > .sb-fmt { flex: none; white-space: nowrap; } /* Font name shares the theme-name treatment; only truncate long family names. */ .sb-font .sb-font-name { flex: none; diff --git a/tests/autosave-tabs.test.mjs b/tests/autosave-tabs.test.mjs index 8902e51..b5d8b95 100644 --- a/tests/autosave-tabs.test.mjs +++ b/tests/autosave-tabs.test.mjs @@ -10,6 +10,7 @@ globalThis.__tabPlatform = { writeShadow: async (key, value) => { shadows.set(key, value); }, clearShadow: async (key) => { shadows.delete(key); }, }; +globalThis.__tabShadows = shadows; const out = fileURLToPath(new URL("./.build/autosave-tabs.mjs", import.meta.url)); await build({ stdin: { contents: 'export * from "./src/store"; export * from "./src/autosave"; export { createRoot } from "solid-js";', resolveDir: fileURLToPath(new URL("..", import.meta.url)) }, @@ -19,7 +20,7 @@ await build({ build.onResolve({ filter: /^\.\/settings$/ }, () => ({ path: "settings", namespace: "stub" })); build.onLoad({ filter: /.*/, namespace: "stub" }, ({ path }) => ({ contents: path === "settings" ? 'export const addRecentFile = async () => {};' - : 'export const {writeShadow, clearShadow} = globalThis.__tabPlatform; export const listShadows = async () => []; export const readFileEncoded = async () => ({content:""}); export const watchFile = async () => {};', loader: "js" })); + : 'export const {writeShadow, clearShadow} = globalThis.__tabPlatform; export const listShadows = async () => [...globalThis.__tabShadows.values()]; export const readFileEncoded = async () => ({content:"on disk"}); export const watchFile = async () => {};', loader: "js" })); } }], }); const s = await import(out); @@ -44,8 +45,20 @@ try { assert.equal(shadows.has(s.keyForPath("/b.md")), true, "saving A preserves B recovery"); s.removeTab(b); await settle(); assert.equal(shadows.size, 0, "closing dirty tab clears its shadow"); - console.log("6 tab autosave checks passed"); + // Declining recovery keeps the work: the startup prompt skips it, opening + // the file still offers it, and only old declined shadows are cleaned up. + const session = { path: "/d.md", content: "unsaved work\n", savedAt: 1, encoding: "utf-8", hadBom: false }; + shadows.set(s.keyForPath("/d.md"), session); + assert.equal((await s.findRecoverable()).length, 1, "an undeclined shadow is offered at startup"); + await s.declineShadows([session]); + assert.equal(shadows.get(s.keyForPath("/d.md"))?.content, "unsaved work\n", "declining keeps the shadow"); + assert.equal((await s.findRecoverable()).length, 0, "a declined shadow isn't offered at startup again"); + assert.ok(await s.shadowFor("/d.md", "on disk"), "opening the file still offers a declined shadow"); + shadows.set(s.keyForPath("/d.md"), { ...session, declinedAt: Date.now() - 15 * 24 * 3600 * 1000 }); + await s.findRecoverable(); + assert.equal(shadows.has(s.keyForPath("/d.md")), false, "a shadow declined over two weeks ago is cleaned up"); + console.log("11 tab autosave checks passed"); } finally { dispose?.(); globalThis.setInterval = originalInterval; globalThis.clearInterval = originalClear; - delete globalThis.__tabPlatform; + delete globalThis.__tabPlatform; delete globalThis.__tabShadows; } diff --git a/tests/editor-interactions.test.mjs b/tests/editor-interactions.test.mjs index 033ccb8..7c95d41 100644 --- a/tests/editor-interactions.test.mjs +++ b/tests/editor-interactions.test.mjs @@ -67,6 +67,44 @@ const paragraph=mountBlock(ordinary,"A **bold** paragraph");paragraph.setActive( check(!ordinary.querySelector(".block-source-panel"),"ordinary text keeps inline editing"); check(ordinary.querySelector(".source").textContent===paragraph.text(),"inline editing preserves source"); paragraph.dispose();ordinary.remove(); +// Enter in a list: put the caret at a source offset, press Enter, read the source. +const enterAt=async(source,offset)=>{ + const h=document.createElement("div");document.body.append(h); + const b=mountBlock(h,source);b.setActive(true);await flush(); + const src=h.querySelector(".source"); + const walker=document.createTreeWalker(src,NodeFilter.SHOW_TEXT);let node,left=offset; + while((node=walker.nextNode())&&left>node.data.length)left-=node.data.length; + const r=document.createRange();r.setStart(node,left);r.collapse(true); + window.getSelection().removeAllRanges();window.getSelection().addRange(r); + src.dispatchEvent(new window.KeyboardEvent("keydown",{key:"Enter",bubbles:true,cancelable:true}));await flush(); + const out=b.text();b.dispose();h.remove();return out; +}; +// Enter in a list item that has an indented heading and paragraph under it. +const L="1. 01\n \n ### Data\n \n Vib.\n \n2. 02\n \n ### Opaque\n \n Engineers.\n \n3. 03\n \n ### Hard\n \n A model."; +// After "02" (the item goes on below): a new empty line inside item 2, right +// there. Nothing moves and no number is added. +const afterItem=await enterAt(L,L.indexOf("02")+2); +check(afterItem===L.replace("2. 02","2. 02\n \n "), + "Enter after an item's first line adds an empty line inside the item"); +// Enter on that empty line: item 3 after item 2's content; the line goes. +const nextItem=await enterAt(afterItem,afterItem.indexOf("2. 02\n \n ")+"2. 02\n \n ".length); +check(nextItem==="1. 01\n \n ### Data\n \n Vib.\n \n2. 02\n \n ### Opaque\n \n Engineers.\n\n3. \n \n4. 03\n \n ### Hard\n \n A model.", + "Enter on an empty line inside an item adds the next item after the item's content"); +// An existing empty item that holds content: Enter gives it back. +check(await enterAt("1. a\n2. \n ### H","1. a\n2. ".length)==="1. a\n ### H", + "Enter on an empty item that holds content removes it"); +// After the heading inside an item: a new paragraph in the item, no split. +check(await enterAt(L,L.indexOf("### Hard")+8)===L.replace("### Hard","### Hard\n \n "), + "Enter at the end of a heading inside an item stays in the item"); +check(await enterAt(L,L.indexOf("Vib.")+4)===L.replace("Vib.","Vib.\n \n "), + "Enter at the end of a paragraph inside an item starts a new paragraph there"); +// On an empty line inside an item: the next item starts there. +check(await enterAt("1. a\n \n b","1. a\n ".length)==="1. a\n \n b\n\n2. ", + "Enter on the blank line between an item's paragraphs keeps them apart and adds the next item after them"); +check(await enterAt("1. a\n2. b","1. a".length)==="1. a\n2. \n3. b","Enter on a one-line item still inserts right after it"); +check(await enterAt("- a\n- b","- a".length)==="- a\n- \n- b","bullet items are unchanged"); +check(await enterAt("1. a\n\n2. b","1. a".length)==="1. a\n2. \n\n3. b","loose lists renumber across blank lines"); +check(await enterAt("9. a\n10. b","9. a".length)==="9. a\n10. \n11. b","a wider number keeps the content column"); const tableHost=document.createElement("div");document.body.append(tableHost); const table=mountTable(tableHost,"| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"); const source=document.createElement("div");source.className="source";source.innerHTML='
'+[0,1,2].map(()=>'
AB
').join("")+"
";tableHost.append(source);