From 1800737955adea016a33db082744051a69f31b49 Mon Sep 17 00:00:00 2001 From: Srinivas Gowda Date: Mon, 9 Nov 2026 00:13:24 +0530 Subject: [PATCH] Fix AppImage emoji freeze, add close prompt option, fix list editing AppImage (#45): the bundled WebKit's Skia aborts the web process on gradient COLRv1 emoji (e.g. U+1F50D with Fedora's Noto), freezing the window. Inside the AppImage only, hide colour fonts from fontconfig and allow back a bundled bitmap (CBDT) Noto Color Emoji, fetched pinned and checksummed in CI and added through tauri.appimage.conf.json. Close prompt (#40): Settings > Files > "When closing unsaved" can ask "Save changes?" (Save / Don't Save / Cancel) instead of "Close without saving?". A sarala-settings.json next to the app makes settings portable. Settings are now saved atomically. Lists: Enter at the end of an item's first line, when the item has a heading or paragraph below, adds a line inside the item instead of leaving that content under an empty item as a code block. Enter on an indented line stays in the item instead of splitting the list. Numbers are drawn the same way rendered and live, so they no longer jump. Context menu: Copy, Cut and Copy as... act on the selection again (the menu taking focus cleared it, or ended the edit); Copy as Markdown/HTML copy the selection, not the whole document. Autosave: declining "Restore unsaved changes?" keeps the autosave (offered again when the file is opened) instead of deleting it, and the prompt runs once per session so reloads can't stack it. Status bar: the format readout no longer wraps and grows the bar when the caret position gets longer. --- .github/workflows/release.yml | 13 +- .gitignore | 3 + scripts/fetch-emoji-font.sh | 28 +++++ src-tauri/src/appimage_fonts.rs | 142 ++++++++++++++++++++++ src-tauri/src/main.rs | 46 ++++++- src-tauri/tauri.appimage.conf.json | 13 ++ src/App.tsx | 32 +++-- src/autosave.ts | 25 +++- src/commands.ts | 87 +++++++++++--- src/components/Block.tsx | 172 +++++++++++++++++++++++++-- src/components/EditorContextMenu.tsx | 22 ++++ src/components/SettingsModal.tsx | 16 ++- src/menuKeyboard.ts | 14 ++- src/platform.ts | 22 ++++ src/settings.ts | 3 +- src/store.ts | 4 + src/styles/app.css | 22 +++- tests/autosave-tabs.test.mjs | 19 ++- tests/editor-interactions.test.mjs | 38 ++++++ 19 files changed, 666 insertions(+), 55 deletions(-) create mode 100755 scripts/fetch-emoji-font.sh create mode 100644 src-tauri/src/appimage_fonts.rs create mode 100644 src-tauri/tauri.appimage.conf.json 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);