From 60be09a5993ab9d7a1683505007740bdeaebd12d Mon Sep 17 00:00:00 2001 From: wangzhigang Date: Fri, 10 Jul 2026 15:01:24 +0800 Subject: [PATCH 01/24] feat(preview): click to open images in the diagram viewer Reuse the existing DiagramViewerOverlay (already shared by Mermaid and PlantUML) for inline preview images, via a new decorateImages step that mirrors decorateMermaidBlocks / decoratePlantUmlBlocks. - single-click any preview image to open the fullscreen viewer - cursor: zoom-in + draggable=false suppress native image drag - excludes images inside .mdv-plantuml and pre.mdv-mermaid (own affordance) - svg images (*.svg, data:image/svg+xml) work via native support; naturalWidth falls back to the rendered box when svg reports 0 - no change to the overlay component, Mermaid/PlantUML wiring, i18n, or Rust Co-Authored-By: Claude --- src/components/editor/diagram-viewer.tsx | 53 ++++++++++++++++++++++++ src/components/editor/preview.tsx | 3 ++ src/styles/editor/prose.css | 4 ++ 3 files changed, 60 insertions(+) diff --git a/src/components/editor/diagram-viewer.tsx b/src/components/editor/diagram-viewer.tsx index ca2fbe5..7b4420d 100644 --- a/src/components/editor/diagram-viewer.tsx +++ b/src/components/editor/diagram-viewer.tsx @@ -297,6 +297,51 @@ export function decorateMermaidBlocks( return () => cleanups.forEach((fn) => fn()); } +export function decorateImages( + root: HTMLElement, + onOpen: (viewer: DiagramViewerSource) => void, +): () => void { + const cleanups: Array<() => void> = []; + const images = Array.from( + root.querySelectorAll("img:not([data-mdv-image-viewer])"), + ); + + images.forEach((img) => { + // PlantUML previews carry their own open affordance; mermaid renders as + // inline , not , but guard anyway so we never double-bind. + if (img.closest(".mdv-plantuml") || img.closest("pre.mdv-mermaid")) return; + + img.dataset.mdvImageViewer = "true"; + img.draggable = false; + + const open = () => { + const rect = img.getBoundingClientRect(); + // svg may report naturalWidth 0 — fall back to rendered box. + // fit mode (the default) never depends on these, only manual zoom does. + const width = img.naturalWidth || Math.max(1, Math.round(rect.width)); + const height = img.naturalHeight || Math.max(1, Math.round(rect.height)); + onOpen({ + svg: `${escapeViewerAttr(img.alt || `, + width, + height, + }); + }; + + const onClick = (e: MouseEvent) => { + e.preventDefault(); + open(); + }; + + img.addEventListener("click", onClick); + cleanups.push(() => { + img.removeEventListener("click", onClick); + delete img.dataset.mdvImageViewer; + }); + }); + + return () => cleanups.forEach((fn) => fn()); +} + function clampDiagramScale(scale: number): number { return Math.min(MAX_DIAGRAM_SCALE, Math.max(MIN_DIAGRAM_SCALE, scale)); } @@ -355,3 +400,11 @@ function svgSize(svg: SVGSVGElement): { width: number; height: number } { const rect = svg.getBoundingClientRect(); return { width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) }; } + +function escapeViewerAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} diff --git a/src/components/editor/preview.tsx b/src/components/editor/preview.tsx index fff34bc..033295a 100644 --- a/src/components/editor/preview.tsx +++ b/src/components/editor/preview.tsx @@ -10,6 +10,7 @@ import { basename, isCsvPath } from "@/lib"; import { CsvPreview } from "./csv-preview"; import { createDiagramViewer, + decorateImages, decorateMermaidBlocks, DiagramViewerOverlay, type DiagramViewer, @@ -199,9 +200,11 @@ export function Preview({ source, filePath }: PreviewProps) { if (!articleRef.current || csvPreview) return; const cleanupCode = decorateCodeBlocks(articleRef.current); const cleanupPlantUml = decoratePlantUmlBlocks(articleRef.current, openDiagramViewer, viewerLabels); + const cleanupImages = decorateImages(articleRef.current, openDiagramViewer); return () => { cleanupCode(); cleanupPlantUml(); + cleanupImages(); }; }, [html, csvPreview, openDiagramViewer, viewerLabels]); diff --git a/src/styles/editor/prose.css b/src/styles/editor/prose.css index defa3d0..b10e4ef 100644 --- a/src/styles/editor/prose.css +++ b/src/styles/editor/prose.css @@ -537,6 +537,10 @@ border: 1px solid var(--border); } +.mdv-prose img[data-mdv-image-viewer="true"] { + cursor: zoom-in; +} + .mdv-prose video, .mdv-prose audio { display: block; From 2d238d5ad727d9fc334a8bc7aa440effad957bec Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Fri, 10 Jul 2026 22:34:03 +0800 Subject: [PATCH 02/24] fix(preview): preserve linked image navigation --- src/components/editor/diagram-viewer.tsx | 7 +- tests/diagram-viewer.test.ts | 84 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/diagram-viewer.test.ts diff --git a/src/components/editor/diagram-viewer.tsx b/src/components/editor/diagram-viewer.tsx index 7b4420d..76dbbc3 100644 --- a/src/components/editor/diagram-viewer.tsx +++ b/src/components/editor/diagram-viewer.tsx @@ -309,7 +309,11 @@ export function decorateImages( images.forEach((img) => { // PlantUML previews carry their own open affordance; mermaid renders as // inline , not , but guard anyway so we never double-bind. - if (img.closest(".mdv-plantuml") || img.closest("pre.mdv-mermaid")) return; + if ( + img.closest(".mdv-plantuml") || + img.closest("pre.mdv-mermaid") || + img.closest("a[href]") + ) return; img.dataset.mdvImageViewer = "true"; img.draggable = false; @@ -329,6 +333,7 @@ export function decorateImages( const onClick = (e: MouseEvent) => { e.preventDefault(); + e.stopPropagation(); open(); }; diff --git a/tests/diagram-viewer.test.ts b/tests/diagram-viewer.test.ts new file mode 100644 index 0000000..1611534 --- /dev/null +++ b/tests/diagram-viewer.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { + decorateImages, + type DiagramViewerSource, +} from "../src/components/editor/diagram-viewer"; + +test("opens decorated images without bubbling into preview handlers", () => { + let clickListener: ((event: MouseEvent) => void) | undefined; + let prevented = false; + let stopped = false; + const image = { + alt: 'quoted " alt', + dataset: {} as DOMStringMap, + draggable: true, + naturalHeight: 0, + naturalWidth: 0, + src: "data:image/svg+xml,", + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === "click") clickListener = listener as (event: MouseEvent) => void; + }, + closest: () => null, + getBoundingClientRect: () => ({ height: 240.4, width: 320.6 }), + removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === "click" && clickListener === listener) clickListener = undefined; + }, + }; + const root = { + querySelectorAll: () => [image], + } as unknown as HTMLElement; + let opened: DiagramViewerSource | undefined; + + const cleanup = decorateImages(root, (viewer) => { + opened = viewer; + }); + + clickListener?.({ + preventDefault: () => { + prevented = true; + }, + stopPropagation: () => { + stopped = true; + }, + } as MouseEvent); + + expect(prevented).toBe(true); + expect(stopped).toBe(true); + expect(opened).toEqual({ + svg: 'quoted " alt', + width: 321, + height: 240, + }); + expect(image.dataset.mdvImageViewer).toBe("true"); + expect(image.draggable).toBe(false); + + cleanup(); + + expect(clickListener).toBeUndefined(); + expect(image.dataset.mdvImageViewer).toBeUndefined(); +}); + +test("leaves linked images to their anchor navigation", () => { + let listenerAdded = false; + const link = {}; + const image = { + dataset: {} as DOMStringMap, + draggable: true, + addEventListener: () => { + listenerAdded = true; + }, + closest: (selector: string) => selector === "a[href]" ? link : null, + }; + const root = { + querySelectorAll: () => [image], + } as unknown as HTMLElement; + + const cleanup = decorateImages(root, () => { + throw new Error("linked images must not open the viewer"); + }); + + expect(listenerAdded).toBe(false); + expect(image.dataset.mdvImageViewer).toBeUndefined(); + expect(image.draggable).toBe(true); + cleanup(); +}); From 285fdb95bd693af21dff6cc57d337499d98e0976 Mon Sep 17 00:00:00 2001 From: Matthew Enarle <89822774+mattenarle10@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:24:07 +0800 Subject: [PATCH 03/24] feat(preview): unify reading width and add outline (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preview): unified reading width + outline (TOC) panel Two reading-mode enhancements: 1. Unify preview width into the reading-width control - Remove the standalone "fill width" toggle; its behavior (fill viewport + 100% tables + tighter padding) moves into the "full" step of the existing reading-width slider. - Split preview now follows reading-width too — .mdv-prose uses the --mdv-reading-prose-width variable instead of a hardcoded 720px. - One width control (narrow / comfort / wide / full), effective in both split and reading modes, persisted. 2. Add a docked table-of-contents (outline) panel for reading mode - Toggle from the reading-mode title bar or the command palette. - Reads the rendered headings (each already carries a GitHub-style slug id from the markdown renderer) and scrolls to the chosen heading on click. State persisted. Co-Authored-By: Claude * fix(preview): refine outline behavior * fix(editor): tighten gutter inset * style(editor): define line number gutter * polish editor gutter --------- Co-authored-by: wangzhigang Co-authored-by: Claude --- src/app.css | 23 ++++++++ src/app.tsx | 18 +++++- src/components/chrome/title-bar.tsx | 15 ++++- src/components/editor/editor.tsx | 19 +++++- src/components/editor/index.ts | 1 + src/components/editor/toc-panel.tsx | 89 +++++++++++++++++++++++++++++ src/lib/commands.ts | 15 +++++ src/lib/storage.ts | 1 + src/locales/en.json | 6 ++ src/styles/editor/prose.css | 2 +- src/styles/editor/toc.css | 86 ++++++++++++++++++++++++++++ tests/commands.test.ts | 58 +++++++++++++++++++ 12 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 src/components/editor/toc-panel.tsx create mode 100644 src/styles/editor/toc.css create mode 100644 tests/commands.test.ts diff --git a/src/app.css b/src/app.css index 0abf8e5..9277df1 100644 --- a/src/app.css +++ b/src/app.css @@ -8,6 +8,7 @@ @import "./styles/editor/panes.css"; @import "./styles/editor/prose.css"; @import "./styles/editor/reading-find.css"; +@import "./styles/editor/toc.css"; @import "./styles/files/sidebar.css"; @import "./styles/overlays/overlay.css"; @import "./styles/overlays/palette.css"; @@ -109,6 +110,28 @@ html.is-mac .mdv-app.has-hidden-titlebar:not(.is-reading) { to { opacity: 1; transform: translateY(0); } } +/* reading width = "full": content fills the pane and multi-column tables + span full width (no horizontal scrollbar). Applies in both the split + preview pane and reading mode. */ +.mdv-app.is-reading-width-full .mdv-preview { + padding-left: 16px; + padding-right: 16px; +} +.mdv-app.is-reading-width-full .mdv-prose { + max-width: none; +} +.mdv-app.is-reading-width-full .mdv-prose table { + width: 100%; +} +/* reading mode's own width/padding rules are more specific, so match them */ +.mdv-app.is-reading-width-full.is-reading .mdv-shell > .mdv-preview { + padding-left: 16px; + padding-right: 16px; +} +.mdv-app.is-reading-width-full.is-reading .mdv-shell > .mdv-preview .mdv-prose { + max-width: none; +} + /* theme transition smoothing — common surfaces fade between palettes */ .mdv-titlebar, .mdv-statusbar, diff --git a/src/app.tsx b/src/app.tsx index 7b05af2..fa87088 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { EditorView } from "@codemirror/view"; import { Breadcrumb, StatusBar, TitleBar, type VimMode } from "@/components/chrome"; -import { Editor, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor"; +import { Editor, OpenTabs, Preview, ReadingFind, Splitter, TocPanel } from "@/components/editor"; import { ContextMenu, Sidebar, type ContextMenuItem } from "@/components/files"; import { AboutOverlay, CommandPalette, DropOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays"; import { TooltipRoot } from "@/components/primitives"; @@ -409,6 +409,10 @@ export function App() { const [findOpen, setFindOpen] = useState(false); const [findFocusRequest, setFindFocusRequest] = useState(0); const [proseEl, setProseEl] = useState(null); + const [tocVisible, setTocVisible] = usePersistedState( + STORAGE_KEYS.tocVisible, + false, + ); useEffect(() => { if (!readingMode) { setProseEl(null); @@ -875,6 +879,8 @@ export function App() { readingMode, editorOnly, toggleEditorOnly, + tocVisible, + toggleToc: () => setTocVisible((v) => !v), contextCount: stagedPaths.length, }, t), [ @@ -901,6 +907,7 @@ export function App() { loadFile, recentFiles, stagedPaths.length, + tocVisible, t, ], ); @@ -909,7 +916,7 @@ export function App() { return (
setTocVisible((v) => !v)} /> + void; onProseFontFamilyChange: (value: ProseFontFamily) => void; onResetWritingDisplay: () => void; + tocVisible?: boolean; + onToggleToc?: () => void; }; export function TitleBar({ @@ -53,6 +55,8 @@ export function TitleBar({ onReadingWidthChange, onProseFontFamilyChange, onResetWritingDisplay, + tocVisible = false, + onToggleToc, }: TitleBarProps) { const { t } = useI18n(); const readingFontIndex = Math.max( @@ -133,6 +137,15 @@ export function TitleBar({ onResetWritingDisplay={onResetWritingDisplay} /> ) : null} + {readingMode && onToggleToc ? ( + + + ))} + + {items.length === 0 ? ( + {t("toc.empty")} + ) : null} + + ); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 2357476..4c25db7 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -13,6 +13,7 @@ import { Download, Info, Leaf, + List, Undo2, Maximize2, Minimize2, @@ -67,6 +68,8 @@ export type CommandActions = { sidebarOpen: boolean; readingMode: boolean; editorOnly: boolean; + tocVisible: boolean; + toggleToc: () => void; contextCount: number; }; @@ -127,6 +130,17 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): action: () => actions.openRecent(path), }), ); + const tocCommands: Command[] = actions.readingMode + ? [{ + id: "toggle-toc", + label: actions.tocVisible ? t("command.hideToc") : t("command.showToc"), + hint: t("command.tocHint"), + icon: List, + category: "view", + keywords: ["toc", "outline", "headings", "contents"], + action: actions.toggleToc, + }] + : []; return [ ...recent, @@ -214,6 +228,7 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): keywords: ["editor", "writing", "focus", "hide preview"], action: actions.toggleEditorOnly, }, + ...tocCommands, { id: "fullscreen", label: t("command.fullscreen"), diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 2459043..23e8b5c 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -20,6 +20,7 @@ export const STORAGE_KEYS = { readingWidth: "mdview.reading.width", proseFontFamily: "mdview.prose.fontFamily", viewMode: "mdview.viewMode", + tocVisible: "mdview.toc.visible", } as const; export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; diff --git a/src/locales/en.json b/src/locales/en.json index 36dad25..f0b66fb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -82,6 +82,9 @@ "title.readingWidth": "reading width", "title.proseFont": "preview font", "title.resetWriting": "reset text", + "title.toc": "outline", + "title.toggleToc": "table of contents", + "toc.empty": "no headings", "writing.font.small": "sm", "writing.font.default": "base", "writing.font.large": "lg", @@ -147,6 +150,9 @@ "command.exitEditorOnly": "exit editor-only", "command.enterEditorOnly": "enter editor-only", "command.editorOnlyHint": "hide the preview — focus on writing", + "command.showToc": "show outline", + "command.hideToc": "hide outline", + "command.tocHint": "table of contents — jump to any heading", "command.fullscreen": "toggle fullscreen", "command.fullscreenHint": "native macOS fullscreen", "command.copyMarkdown": "copy markdown to clipboard", diff --git a/src/styles/editor/prose.css b/src/styles/editor/prose.css index b10e4ef..5c938fa 100644 --- a/src/styles/editor/prose.css +++ b/src/styles/editor/prose.css @@ -1,5 +1,5 @@ .mdv-prose { - max-width: 720px; + max-width: var(--mdv-reading-prose-width); margin: 0 auto; font-family: var(--mdv-prose-font-family); font-size: var(--mdv-prose-font-size); diff --git a/src/styles/editor/toc.css b/src/styles/editor/toc.css new file mode 100644 index 0000000..e9e2bfb --- /dev/null +++ b/src/styles/editor/toc.css @@ -0,0 +1,86 @@ +/* docked table-of-contents outline for reading mode — sits to the right of the + preview pane, scrolls independently, and jumps to a heading on click. */ +.mdv-toc { + flex: 0 0 248px; + width: 248px; + min-height: 0; + overflow-y: auto; + padding: 80px 12px 96px 8px; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 96%, var(--fg) 4%); + scrollbar-width: thin; +} + +.mdv-toc__header { + padding: 0 8px 12px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.mdv-toc__list { + list-style: none; + margin: 0; + padding: 0; +} + +.mdv-toc__item { + margin: 0; +} + +.mdv-toc__link { + display: block; + width: 100%; + text-align: left; + padding: 4px 8px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + transition: + color var(--dur-fast) var(--easing), + background-color var(--dur-fast) var(--easing); +} + +.mdv-toc__link:hover, +.mdv-toc__link:focus-visible { + color: var(--fg); + background: color-mix(in srgb, var(--fg) 6%, transparent); + outline: none; +} + +.mdv-toc__item--1 > .mdv-toc__link { + font-weight: 600; + color: var(--fg); +} + +.mdv-toc__empty { + display: block; + padding: 4px 8px; + font-size: 12px; + color: var(--muted); +} + +@media (max-width: 760px) { + .mdv-app.is-reading .mdv-shell { + position: relative; + } + + .mdv-toc { + position: absolute; + z-index: 4; + inset: 0 0 0 auto; + width: min(248px, calc(100vw - 48px)); + background: var(--bg-solid, var(--bg)); + box-shadow: -12px 0 28px color-mix(in srgb, #000 18%, transparent); + } +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..c7cb1c9 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { + buildCommands, + type CommandActions, +} from "../src/lib/commands"; + +const noop = () => undefined; + +function commandActions( + overrides: Partial = {}, +): CommandActions { + return { + newFile: noop, + openFile: noop, + openFolder: noop, + save: noop, + toggleSidebar: noop, + toggleReading: noop, + toggleEditorOnly: noop, + showHelp: noop, + showWelcome: noop, + showAbout: noop, + loadDemo: noop, + undoFileOp: noop, + checkForUpdates: noop, + copyMarkdown: noop, + copyContextBundle: noop, + clearContextBundle: noop, + exportToPdf: noop, + toggleFullscreen: noop, + openRecent: noop, + recentFiles: [], + hasActivePath: true, + sidebarOpen: false, + readingMode: false, + editorOnly: false, + tocVisible: false, + toggleToc: noop, + contextCount: 0, + ...overrides, + }; +} + +test("shows the outline command only while reading", () => { + const splitCommands = buildCommands(commandActions()); + const readingCommands = buildCommands(commandActions({ readingMode: true })); + + expect(splitCommands.some((command) => command.id === "toggle-toc")).toBe(false); + expect(readingCommands.some((command) => command.id === "toggle-toc")).toBe(true); +}); + +test("labels the outline command from its current visibility", () => { + const shown = buildCommands(commandActions({ readingMode: true, tocVisible: true })); + const hidden = buildCommands(commandActions({ readingMode: true, tocVisible: false })); + + expect(shown.find((command) => command.id === "toggle-toc")?.label).toBe("command.hideToc"); + expect(hidden.find((command) => command.id === "toggle-toc")?.label).toBe("command.showToc"); +}); From 4ebf7ea89bfd1f017c5a7aec9d9be1ee81b3f4dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=99=E6=AD=8C?= Date: Sat, 11 Jul 2026 14:03:15 +0800 Subject: [PATCH 04/24] Added Traditional Chinese (#103) * Add zh-TW * Add Traditional Chinese language support --- src/lib/i18n.tsx | 5 +- src/locales/zh-TW.json | 238 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/locales/zh-TW.json diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index ceb96f6..81b72c5 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -10,15 +10,17 @@ import ja from "@/locales/ja.json"; import ko from "@/locales/ko.json"; import ptBR from "@/locales/pt-BR.json"; import zh from "@/locales/zh.json"; +import zhTW from "@/locales/zh-TW.json"; import { STORAGE_KEYS } from "./storage"; -export type Language = "en" | "ja" | "zh" | "ko" | "es" | "pt-BR" | "it" | "fr" | "de"; +export type Language = "en" | "ja" | "zh" | "zh-TW" | "ko" | "es" | "pt-BR" | "it" | "fr" | "de"; export type Translate = (key: string, vars?: Record) => string; export const LANGUAGE_CHOICES: Array<{ value: Language; label: string; nativeLabel: string }> = [ { value: "en", label: "English", nativeLabel: "English" }, { value: "ja", label: "Japanese", nativeLabel: "日本語" }, { value: "zh", label: "Chinese", nativeLabel: "简体中文" }, + { value: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "正體中文" }, { value: "ko", label: "Korean", nativeLabel: "한국어" }, { value: "es", label: "Spanish", nativeLabel: "Español" }, { value: "pt-BR", label: "Portuguese", nativeLabel: "Português (Brasil)" }, @@ -43,6 +45,7 @@ void i18n.init({ ko: { translation: ko }, "pt-BR": { translation: ptBR }, zh: { translation: zh }, + "zh-TW": { translation: zhTW }, }, interpolation: { escapeValue: false, diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json new file mode 100644 index 0000000..60f333f --- /dev/null +++ b/src/locales/zh-TW.json @@ -0,0 +1,238 @@ +{ + "app.copied": "已複製!", + "app.copyMarkdown": "複製 Markdown", + "app.copyMarkdownShortcut": "複製 Markdown(⌘⇧C)", + "app.exportPdf": "匯出為 PDF", + "app.exportPdfShortcut": "匯出為 PDF(⌘P)", + "app.newFile": "新增檔案", + "app.newFileShortcut": "新增檔案(⌘N)", + "app.openFile": "開啟檔案", + "app.openFileShortcut": "開啟檔案(⌘O)", + "app.openFolder": "開啟資料夾", + "app.openFolderShortcut": "開啟資料夾(⌘⇧O)", + "app.close": "關閉", + "app.closeEsc": "關閉(Esc)", + "app.install": "安裝", + "app.releaseNotes": "版本說明", + "app.latestVersion": "已是最新版本", + "app.fileReloaded": "檔案已在外部變更・已重新載入", + "app.fileConflict": "此檔案已在外部變更・未儲存的編輯將會遺失", + "app.reloadDiscard": "重新載入(捨棄本地變更)", + "app.openDefault": "以預設應用程式開啟", + "app.openAsText": "以純文字開啟", + "app.dropMarkdownOnly": "marka.md 僅支援開啟 .md / .markdown / .mdx / .csv 檔案", + "app.stageFirst": "請先從側邊欄暫存檔案", + "app.pdfFailed": "無法匯出為 PDF", + "app.contextCopyFailed": "無法複製情境包", + "app.savedTo": "已儲存至 {name}", + "app.contextCopied": "已複製情境・{files}・{tokens} tok", + "app.fileSingular": "{count} 個檔案", + "app.filePlural": "{count} 個檔案", + "app.installingVersion": "正在安裝 v{version}…", + "app.updateAvailable": "有可用更新・v{version}", + "breadcrumb.hideSidebar": "隱藏側邊欄", + "breadcrumb.showSidebar": "顯示側邊欄", + "breadcrumb.hideSidebarShortcut": "隱藏側邊欄(⌘B)", + "breadcrumb.showSidebarShortcut": "顯示側邊欄(⌘B)", + "breadcrumb.noFile": "未開啟任何檔案", + "breadcrumb.saving": "儲存中…", + "breadcrumb.unsaved": "未儲存", + "breadcrumb.saved": "已儲存", + "breadcrumb.path": "路徑", + "sidebar.noFolder": "未開啟資料夾", + "sidebar.explorer": "檔案總管", + "sidebar.favorites": "我的最愛", + "sidebar.noFavorites": "尚無我的最愛 — 為檔案加上星號以釘選至此處", + "sidebar.addFolder": "新增資料夾", + "sidebar.closeFolder": "關閉資料夾", + "sidebar.unfavorite": "從我的最愛中移除", + "sidebar.searchFolder": "搜尋資料夾", + "sidebar.closeSearch": "關閉搜尋", + "sidebar.closeSearchShortcut": "關閉搜尋(Esc)", + "sidebar.searchPlaceholder": "搜尋檔案…", + "sidebar.browseNotes": "瀏覽您的 Markdown 筆記", + "sidebar.context": "情境", + "sidebar.copyContext": "複製已暫存的情境", + "sidebar.clearContext": "清除情境", + "sidebar.resize": "調整側邊欄大小", + "sidebar.tokens": "{tokens} tok", + "title.hideBreadcrumb": "隱藏工具列", + "title.showBreadcrumb": "顯示工具列", + "title.unsavedChanges": "有未儲存的變更", + "title.exitReading": "離開閱讀模式", + "title.exitReadingTooltip": "離開閱讀模式(Esc)", + "title.readingMode": "閱讀模式", + "title.readingModeShortcut": "閱讀模式(⌘.)", + "title.theme": "佈景主題", + "title.themeTooltip": "佈景主題與透明度", + "title.transparency": "透明度", + "title.off": "關閉", + "title.percentTransparent": "{percent}% 透明", + "title.display": "顯示", + "title.editor": "編輯器", + "title.vimMode": "Vim 模式", + "title.language": "語言", + "title.writing": "文字", + "title.writingFont": "大小", + "title.writingSpacing": "間距", + "title.readingZoom": "閱讀縮放", + "title.readingZoomOut": "縮小", + "title.readingZoomIn": "放大", + "title.resetReadingZoom": "重設縮放", + "title.readingWidth": "閱讀寬度", + "title.proseFont": "預覽字型", + "title.resetWriting": "重設文字", + "title.toc": "大綱", + "title.toggleToc": "目錄", + "toc.empty": "無標題", + "writing.font.small": "小", + "writing.font.default": "預設", + "writing.font.large": "大", + "writing.font.x-large": "特大", + "writing.spacing.compact": "緊湊", + "writing.spacing.comfortable": "舒適", + "writing.spacing.airy": "寬鬆", + "reading.width.narrow": "窄", + "reading.width.comfortable": "舒適", + "reading.width.wide": "寬", + "reading.width.full": "全寬", + "prose.font.inter": "Inter", + "prose.font.system": "系統字型", + "prose.font.mono": "等寬字型", + "theme.group.neutral": "中性", + "theme.group.catppuccin": "Catppuccin", + "theme.group.ai": "AI", + "theme.group.crafted": "精選", + "menu.copyPath": "複製路徑", + "menu.copyRelativePath": "複製相對路徑", + "menu.pathCopied": "路徑已複製", + "menu.rename": "重新命名", + "menu.newFile": "新增檔案", + "menu.newFolder": "新增資料夾", + "menu.revealFinder": "在 Finder 中顯示", + "menu.revealExplorer": "在檔案總管中顯示", + "menu.openDefault": "以預設應用程式開啟", + "menu.delete": "刪除", + "menu.deleteFolder": "刪除資料夾", + "menu.confirmDelete": "刪除「{name}」?\n\n此操作無法復原。", + "menu.confirmDeleteFolder": "刪除資料夾「{name}」及其所有內容?\n\n此操作無法復原。", + "command.recent": "最近使用", + "command.file": "檔案", + "command.view": "檢視", + "command.edit": "編輯", + "command.share": "分享", + "command.theme": "佈景主題", + "command.help": "說明", + "command.other": "其他", + "command.placeholder": "搜尋指令、佈景主題、檔案…", + "command.noMatches": "無符合結果", + "command.navigate": "瀏覽", + "command.run": "執行", + "command.close": "關閉", + "command.recentHint": "最近使用・{dir}", + "command.openFolderLabel": "開啟資料夾…", + "command.openFolderHint": "載入一個筆記資料夾,作為您的情境資料庫", + "command.openFileLabel": "開啟檔案…", + "command.openFileHint": "從磁碟選取單一 .md 或 .csv 檔案", + "command.newFileHint": "建立空白的 Markdown 緩衝區", + "command.save": "儲存", + "command.saveHintReady": "將變更寫入磁碟", + "command.saveHintEmpty": "尚未載入任何檔案 — 請先開啟一個檔案", + "command.undoFileOp": "復原上一個檔案操作", + "command.undoFileOpHint": "還原上一次移動/重新命名/新增檔案/新增資料夾的操作", + "command.sidebarHint": "資料夾樹狀結構與檔案搜尋", + "command.hideSidebar": "隱藏側邊欄", + "command.showSidebar": "顯示側邊欄", + "command.exitReading": "離開閱讀模式", + "command.enterReading": "進入閱讀模式", + "command.backToSplit": "返回分割編輯器與預覽", + "command.readingHint": "純預覽模式 — 非常適合分享前的校對", + "command.exitEditorOnly": "離開純編輯器模式", + "command.enterEditorOnly": "進入純編輯器模式", + "command.editorOnlyHint": "隱藏預覽 — 專注於寫作", + "command.showToc": "顯示大綱", + "command.hideToc": "隱藏大綱", + "command.tocHint": "目錄 — 跳至任意標題", + "command.fullscreen": "切換全螢幕", + "command.fullscreenHint": "macOS 原生全螢幕", + "command.copyMarkdown": "複製 Markdown 至剪貼簿", + "command.copyMarkdownHint": "可分享給任何 AI — 直接貼入對話", + "command.copyContext": "複製情境包", + "command.copyContextHint": "{count} 個已暫存的{files} → 一份整潔的情境包", + "command.clearContext": "清除情境包", + "command.clearContextHintReady": "移除所有已暫存的檔案", + "command.clearContextHintEmpty": "沒有已暫存的檔案", + "command.exportPdfHint": "開啟列印檢視,具有穩定的頁面邊距", + "command.themePrefix": "佈景主題:{theme}", + "command.transparencyOn": "透明度:開啟(74%)", + "command.transparencyOnHint": "macOS 毛玻璃效果透視視窗 — 可在佈景主題選單中調整不透明度", + "command.transparencyOff": "透明度:關閉", + "command.transparencyOffHint": "純色視窗背景", + "command.showHelp": "顯示說明", + "command.showHelpHint": "鍵盤快速鍵與使用技巧", + "command.demo": "顯示示範文件", + "command.demoHint": "將功能導覽的 Markdown 載入編輯器", + "command.tutorial": "顯示快速入門教學", + "command.tutorialHint": "重新播放六步驟的新手引導", + "command.checkUpdates": "檢查更新", + "command.checkUpdatesHint": "確認是否有更新版本的 marka.md", + "command.about": "關於 marka.md", + "command.aboutHint": "版本、授權條款、相關連結", + "help.aria": "如何使用 marka.md", + "help.subtitle": "快速入門與快速鍵", + "help.features": "功能特色", + "help.feature.markdown.title": "Markdown", + "help.feature.markdown.body": "預覽、Mermaid、PlantUML", + "help.feature.context.title": "情境", + "help.feature.context.body": "暫存檔案並顯示 Token 數量", + "help.feature.csv.title": "CSV 預覽", + "help.feature.csv.body": "以唯讀表格開啟 .csv 檔案", + "help.feature.themes.title": "分享", + "help.feature.themes.body": "佈景主題、複製、整潔 PDF", + "help.shortcuts": "快速鍵", + "help.tips": "使用技巧", + "help.replay": "重新播放教學", + "help.file": "檔案", + "help.view": "檢視", + "help.edit": "編輯", + "help.share": "分享", + "help.help": "說明", + "help.openFolder": "開啟筆記資料夾", + "help.openFile": "開啟 .md 或 .csv", + "help.newUntitled": "新增未命名緩衝區", + "help.newTab": "新增未命名分頁", + "help.saveCurrent": "儲存目前檔案", + "help.saveAs": "另存新檔", + "help.undoSidebar": "復原上一個檔案操作", + "help.openPalette": "開啟指令選擇區", + "help.showHideSidebar": "顯示/隱藏側邊欄", + "help.switchTab": "切換至第 1-9 個分頁", + "help.toggleReading": "切換閱讀模式", + "help.toggleEditorOnly": "切換純編輯器模式", + "help.toggleFullscreen": "切換全螢幕", + "help.findReplace": "在編輯器中尋找/取代", + "help.findNext": "尋找下一個符合項目", + "help.copyMarkdown": "複製 Markdown 至剪貼簿", + "help.exportPdf": "匯出為 PDF", + "help.openThis": "開啟此說明", + "help.closeAny": "關閉任何彈出視窗/覆蓋層", + "help.tip1": "從資料夾開始:側邊欄將成為您可搜尋的筆記資料庫,適合撰寫與情境整理工作。", + "help.tip2": "為重要檔案加上星號以快速存取,再使用分頁保持活躍草稿與參考資料開啟中。", + "help.tip3": "暫存側邊欄的檔案,以顯示含有檔案數量與 Token 估算的情境匣。", + "help.tip4": "需要一份 AI 就緒的整合包時,請使用「複製情境」;只需要目前檔案時,請使用「複製 Markdown」。", + "help.tip5": "按下 ⌘. 進入閱讀模式,再調整大小、寬度、佈景主題並在校對時匯出。", + "help.tip6": "PlantUML 預覽僅在請求時載入;Mermaid 則會在預覽中即時渲染。", + "help.tip7": "開啟 .csv 檔案可快速以唯讀表格檢視,無需離開編輯器。", + "help.tip8": "使用 ⌘K 完成所有操作:檔案、檢視、佈景主題、情境、PDF 匯出、更新、說明與教學重播。", + "diagram.openViewer": "開啟圖表檢視器", + "diagram.viewerLabel": "圖表檢視器", + "diagram.viewerTitle": "圖表檢視器", + "diagram.zoomIn": "放大", + "diagram.zoomOut": "縮小", + "diagram.fit": "符合視窗", + "diagram.fitToWindow": "符合視窗大小", + "diagram.actualSize": "實際大小", + "tabs.openFiles": "已開啟的檔案", + "tabs.close": "關閉 {name}", + "tabs.closeUnsaved": "「{name}」有未儲存的變更,確定要關閉?" +} From 343f96088350238f7789e956eb7e55a8eba04aa4 Mon Sep 17 00:00:00 2001 From: Matthew Enarle <89822774+mattenarle10@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:30:50 +0800 Subject: [PATCH 05/24] feat(editor): add markdown insertion commands (#104) --- src/app.tsx | 26 ++++++++ src/components/overlays/help-overlay.tsx | 3 +- src/components/overlays/welcome-overlay.tsx | 4 +- src/lib/commands.ts | 63 ++++++++++++++++--- src/lib/index.ts | 5 ++ src/lib/markdown-insertions.ts | 68 +++++++++++++++++++++ src/locales/en.json | 23 +++++-- tests/commands.test.ts | 20 ++++++ tests/markdown-insertions.test.ts | 43 +++++++++++++ 9 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 src/lib/markdown-insertions.ts create mode 100644 tests/markdown-insertions.test.ts diff --git a/src/app.tsx b/src/app.tsx index fa87088..599b1ea 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -37,6 +37,7 @@ import { getContextBundleStats, getWhatsNewToastMessage, isSupportedTextPath, + markdownInsertion, normalizeProseFontFamily, normalizeReadingFontSize, normalizeReadingWidth, @@ -50,6 +51,7 @@ import { removeEntry, STORAGE_KEYS, useI18n, + type MarkdownInsertion, type ProseFontFamily, type ReadingFontSize, type ReadingWidth, @@ -509,6 +511,28 @@ export function App() { }, []); const editorViewRef = useRef(null); + const insertMarkdown = useCallback((kind: MarkdownInsertion) => { + const view = editorViewRef.current; + if (!view) { + const insertion = markdownInsertion(kind); + const prefix = source.length > 0 && !source.endsWith("\n") ? "\n\n" : ""; + setSource(`${source}${prefix}${insertion.text}`); + return; + } + + const { from, to } = view.state.selection.main; + const selected = view.state.sliceDoc(from, to); + const insertion = markdownInsertion(kind, selected); + view.dispatch({ + changes: { from, to, insert: insertion.text }, + selection: { + anchor: from + insertion.selectionFrom, + head: from + insertion.selectionTo, + }, + scrollIntoView: true, + }); + view.focus(); + }, [setSource, source]); // proportional editor <-> preview scroll sync; rebinds when active file changes useSyncScroll({ rebindKey: activePath ?? "untitled" }); @@ -871,6 +895,7 @@ export function App() { copyContextBundle, clearContextBundle, exportToPdf, + insertMarkdown, toggleFullscreen, openRecent: (path: string) => void loadFile(path), recentFiles, @@ -902,6 +927,7 @@ export function App() { handleUndoFileOp, handleManualUpdateCheck, exportToPdf, + insertMarkdown, toggleFullscreen, handleToggleSidebar, loadFile, diff --git a/src/components/overlays/help-overlay.tsx b/src/components/overlays/help-overlay.tsx index 9cad4b6..2fa4c31 100644 --- a/src/components/overlays/help-overlay.tsx +++ b/src/components/overlays/help-overlay.tsx @@ -55,7 +55,7 @@ function getGroups(t: Translate): Group[] { ], }, { - title: t("help.view"), + title: t("help.workspace"), rows: [ { keys: "⌘+K", label: t("help.openPalette") }, { keys: "⌘+B", label: t("help.showHideSidebar") }, @@ -68,6 +68,7 @@ function getGroups(t: Translate): Group[] { { title: t("help.edit"), rows: [ + { keys: "⌘+K", label: t("help.insertMarkdown") }, { keys: "⌘+F", label: t("help.findReplace") }, { keys: "⌘+G", label: t("help.findNext") }, ], diff --git a/src/components/overlays/welcome-overlay.tsx b/src/components/overlays/welcome-overlay.tsx index 1648aa4..1b8316b 100644 --- a/src/components/overlays/welcome-overlay.tsx +++ b/src/components/overlays/welcome-overlay.tsx @@ -44,7 +44,7 @@ const SLIDES: Slide[] = [ title: "write with live preview", body: ( <> - type on the left, preview on the right. markdown, code, mermaid, plantuml, tasks, csv, video, and audio previews all stay close to the draft. + type on the left, preview on the right. use to insert tables, lists, and code blocks without leaving the keyboard. ), }, @@ -71,7 +71,7 @@ const SLIDES: Slide[] = [ title: "make it yours", body: ( <> - use for commands, themes, language, updates, help, and the demo doc. happy writing. + use for files, workspace actions, markdown inserts, themes, updates, help, and the demo doc. happy writing. ), }, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 4c25db7..1f7f0bf 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -3,6 +3,7 @@ import { BookOpen, Circle, CircleHelp, + Code2, Copy, FilePlus2, FileDown, @@ -14,6 +15,8 @@ import { Info, Leaf, List, + ListOrdered, + Table2, Undo2, Maximize2, Minimize2, @@ -29,7 +32,7 @@ import { basename, dirname } from "./files"; import { setThemeMode, setTransparency, THEME_CHOICES, THEME_HINTS, type ThemeMode } from "./theme"; import type { Translate } from "./i18n"; -export type CommandCategory = "recent" | "file" | "view" | "edit" | "share" | "theme" | "help"; +export type CommandCategory = "recent" | "file" | "workspace" | "edit" | "share" | "theme" | "help"; export type Command = { id: string; @@ -61,6 +64,7 @@ export type CommandActions = { copyContextBundle: () => void | Promise; clearContextBundle: () => void; exportToPdf: () => void; + insertMarkdown: (kind: "table-2x2" | "table-3x3" | "unordered-list" | "ordered-list" | "code-block") => void; toggleFullscreen: () => void | Promise; openRecent: (path: string) => void; recentFiles: readonly string[]; @@ -102,7 +106,7 @@ const THEME_COMMANDS: Array<{ mode: ThemeMode; label: string; hint: string; icon export const CATEGORY_ORDER: CommandCategory[] = [ "recent", "file", - "view", + "workspace", "edit", "share", "theme", @@ -136,7 +140,7 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): label: actions.tocVisible ? t("command.hideToc") : t("command.showToc"), hint: t("command.tocHint"), icon: List, - category: "view", + category: "workspace", keywords: ["toc", "outline", "headings", "contents"], action: actions.toggleToc, }] @@ -200,7 +204,7 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): hint: t("command.sidebarHint"), shortcut: "⌘B", icon: actions.sidebarOpen ? PanelLeftClose : PanelLeftOpen, - category: "view", + category: "workspace", keywords: ["sidebar", "explorer", "tree", "files"], action: actions.toggleSidebar, }, @@ -212,7 +216,7 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): : t("command.readingHint"), shortcut: "⌘.", icon: actions.readingMode ? Minimize2 : BookOpen, - category: "view", + category: "workspace", keywords: ["reading", "preview", "proof", "focus"], action: actions.toggleReading, }, @@ -224,10 +228,55 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): : t("command.editorOnlyHint"), shortcut: "⌘⇧.", icon: actions.editorOnly ? Minimize2 : FileText, - category: "view", + category: "workspace", keywords: ["editor", "writing", "focus", "hide preview"], action: actions.toggleEditorOnly, }, + { + id: "insert-table-2x2", + label: t("command.insertTable2x2"), + hint: t("command.insertTable2x2Hint"), + icon: Table2, + category: "edit", + keywords: ["insert", "table", "grid", "markdown", "rows", "columns"], + action: () => actions.insertMarkdown("table-2x2"), + }, + { + id: "insert-table-3x3", + label: t("command.insertTable3x3"), + hint: t("command.insertTable3x3Hint"), + icon: Table2, + category: "edit", + keywords: ["insert", "table", "grid", "markdown", "rows", "columns"], + action: () => actions.insertMarkdown("table-3x3"), + }, + { + id: "insert-unordered-list", + label: t("command.insertUnorderedList"), + hint: t("command.insertUnorderedListHint"), + icon: List, + category: "edit", + keywords: ["insert", "list", "bullet", "bullets", "unordered", "markdown"], + action: () => actions.insertMarkdown("unordered-list"), + }, + { + id: "insert-ordered-list", + label: t("command.insertOrderedList"), + hint: t("command.insertOrderedListHint"), + icon: ListOrdered, + category: "edit", + keywords: ["insert", "list", "numbered", "numbers", "ordered", "markdown"], + action: () => actions.insertMarkdown("ordered-list"), + }, + { + id: "insert-code-block", + label: t("command.insertCodeBlock"), + hint: t("command.insertCodeBlockHint"), + icon: Code2, + category: "edit", + keywords: ["insert", "code", "fence", "block", "markdown", "highlight"], + action: () => actions.insertMarkdown("code-block"), + }, ...tocCommands, { id: "fullscreen", @@ -235,7 +284,7 @@ export function buildCommands(actions: CommandActions, t: Translate = defaultT): hint: t("command.fullscreenHint"), shortcut: "⌃⌘F", icon: Maximize2, - category: "view", + category: "workspace", keywords: ["fullscreen", "window", "native"], action: actions.toggleFullscreen, }, diff --git a/src/lib/index.ts b/src/lib/index.ts index 911829b..0db527d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -52,6 +52,11 @@ export { export { CHANGELOG_URL, getWhatsNewToastMessage } from "./release-notes"; export { buildCommands, type Command, type CommandActions } from "./commands"; export { filterAndRankCommands } from "./command-search"; +export { + markdownInsertion, + type MarkdownInsertion, + type MarkdownInsertionResult, +} from "./markdown-insertions"; export { estimateTokens, formatTokens } from "./bundle"; export { CSV_PREVIEW_MAX_COLUMNS, diff --git a/src/lib/markdown-insertions.ts b/src/lib/markdown-insertions.ts new file mode 100644 index 0000000..869f8db --- /dev/null +++ b/src/lib/markdown-insertions.ts @@ -0,0 +1,68 @@ +export type MarkdownInsertion = + | "table-2x2" + | "table-3x3" + | "unordered-list" + | "ordered-list" + | "code-block"; + +export type MarkdownInsertionResult = { + text: string; + selectionFrom: number; + selectionTo: number; +}; + +function buildTable(columns: number, rows: number): MarkdownInsertionResult { + const headers = Array.from({ length: columns }, (_, i) => `column ${i + 1}`); + const divider = Array.from({ length: columns }, () => "---"); + const body = Array.from({ length: rows }, () => Array.from({ length: columns }, () => " ").join(" | ")); + const lines = [ + `| ${headers.join(" | ")} |`, + `| ${divider.join(" | ")} |`, + ...body.map((row) => `| ${row} |`), + ]; + const text = `${lines.join("\n")}\n`; + const firstCellStart = lines[0].length + 1 + lines[1].length + 1 + 2; + return { + text, + selectionFrom: firstCellStart, + selectionTo: firstCellStart, + }; +} + +function linePrefixSelection(selection: string, formatLine: (line: string, index: number) => string): MarkdownInsertionResult { + const lines = selection.length > 0 ? selection.split("\n") : [""]; + const text = lines.map(formatLine).join("\n"); + const cursor = text.length; + return { + text, + selectionFrom: cursor, + selectionTo: cursor, + }; +} + +function codeBlock(selection: string): MarkdownInsertionResult { + const language = ""; + const body = selection.length > 0 ? selection : ""; + const text = `\`\`\`${language}\n${body}\n\`\`\``; + const cursor = body.length > 0 ? text.length : 4; + return { + text, + selectionFrom: cursor, + selectionTo: cursor, + }; +} + +export function markdownInsertion(kind: MarkdownInsertion, selection = ""): MarkdownInsertionResult { + switch (kind) { + case "table-2x2": + return buildTable(2, 2); + case "table-3x3": + return buildTable(3, 3); + case "unordered-list": + return linePrefixSelection(selection, (line) => `- ${line}`); + case "ordered-list": + return linePrefixSelection(selection, (line, i) => `${i + 1}. ${line}`); + case "code-block": + return codeBlock(selection); + } +} diff --git a/src/locales/en.json b/src/locales/en.json index f0b66fb..6559008 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -118,7 +118,7 @@ "menu.confirmDeleteFolder": "delete folder \"{name}\" and everything inside it?\n\nthis cannot be undone.", "command.recent": "recent", "command.file": "file", - "command.view": "view", + "command.workspace": "workspace", "command.edit": "edit", "command.share": "share", "command.theme": "theme", @@ -150,6 +150,16 @@ "command.exitEditorOnly": "exit editor-only", "command.enterEditorOnly": "enter editor-only", "command.editorOnlyHint": "hide the preview — focus on writing", + "command.insertTable2x2": "insert 2x2 table", + "command.insertTable2x2Hint": "add a compact markdown table", + "command.insertTable3x3": "insert 3x3 table", + "command.insertTable3x3Hint": "add a larger markdown table", + "command.insertUnorderedList": "insert bullet list", + "command.insertUnorderedListHint": "turn selected lines into bullets", + "command.insertOrderedList": "insert numbered list", + "command.insertOrderedListHint": "turn selected lines into numbered items", + "command.insertCodeBlock": "insert code block", + "command.insertCodeBlockHint": "wrap selection in a fenced code block", "command.showToc": "show outline", "command.hideToc": "hide outline", "command.tocHint": "table of contents — jump to any heading", @@ -179,21 +189,21 @@ "command.about": "about marka.md", "command.aboutHint": "version, license, links", "help.aria": "how to use marka.md", - "help.subtitle": "quickstart + shortcuts", + "help.subtitle": "quickstart + command map", "help.features": "features", "help.feature.markdown.title": "markdown", - "help.feature.markdown.body": "preview, mermaid, plantuml", + "help.feature.markdown.body": "preview, diagrams, insert helpers", "help.feature.context.title": "context", "help.feature.context.body": "stage files with token counts", "help.feature.csv.title": "csv preview", "help.feature.csv.body": "open .csv as read-only tables", "help.feature.themes.title": "share", "help.feature.themes.body": "themes, copy, clean pdf", - "help.shortcuts": "shortcuts", + "help.shortcuts": "command map", "help.tips": "tips", "help.replay": "replay tutorial", "help.file": "file", - "help.view": "view", + "help.workspace": "workspace", "help.edit": "edit", "help.share": "share", "help.help": "help", @@ -212,6 +222,7 @@ "help.toggleFullscreen": "toggle fullscreen", "help.findReplace": "find / replace in editor", "help.findNext": "find next match", + "help.insertMarkdown": "insert tables, lists, code blocks", "help.copyMarkdown": "copy markdown to clipboard", "help.exportPdf": "export to pdf", "help.openThis": "open this help", @@ -223,7 +234,7 @@ "help.tip5": "press ⌘. for reading mode, then tune size, width, theme, and export while proofing.", "help.tip6": "plantuml previews load only when requested; mermaid renders live in the preview.", "help.tip7": "open .csv files for quick read-only tables without leaving the editor.", - "help.tip8": "use ⌘K for everything: files, views, themes, context, pdf export, updates, help, and tutorial replay.", + "help.tip8": "use ⌘K for files, workspace actions, markdown inserts, themes, context, pdf export, updates, and help.", "diagram.openViewer": "open diagram viewer", "diagram.viewerLabel": "diagram viewer", "diagram.viewerTitle": "diagram viewer", diff --git a/tests/commands.test.ts b/tests/commands.test.ts index c7cb1c9..27c62fd 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -27,6 +27,7 @@ function commandActions( copyContextBundle: noop, clearContextBundle: noop, exportToPdf: noop, + insertMarkdown: noop, toggleFullscreen: noop, openRecent: noop, recentFiles: [], @@ -56,3 +57,22 @@ test("labels the outline command from its current visibility", () => { expect(shown.find((command) => command.id === "toggle-toc")?.label).toBe("command.hideToc"); expect(hidden.find((command) => command.id === "toggle-toc")?.label).toBe("command.showToc"); }); + +test("includes markdown insertion commands", () => { + const commands = buildCommands(commandActions()); + const ids = commands.map((command) => command.id); + + expect(ids).toContain("insert-table-2x2"); + expect(ids).toContain("insert-table-3x3"); + expect(ids).toContain("insert-unordered-list"); + expect(ids).toContain("insert-ordered-list"); + expect(ids).toContain("insert-code-block"); +}); + +test("groups layout commands under workspace instead of view", () => { + const commands = buildCommands(commandActions()); + + expect(commands.some((command) => String(command.category) === "view")).toBe(false); + expect(commands.find((command) => command.id === "toggle-reading")?.category).toBe("workspace"); + expect(commands.find((command) => command.id === "toggle-sidebar")?.category).toBe("workspace"); +}); diff --git a/tests/markdown-insertions.test.ts b/tests/markdown-insertions.test.ts new file mode 100644 index 0000000..6190bf3 --- /dev/null +++ b/tests/markdown-insertions.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { markdownInsertion } from "../src/lib/markdown-insertions"; + +test("builds a 2x2 markdown table", () => { + expect(markdownInsertion("table-2x2").text).toBe([ + "| column 1 | column 2 |", + "| --- | --- |", + "| | |", + "| | |", + "", + ].join("\n")); +}); + +test("builds a 3x3 markdown table", () => { + expect(markdownInsertion("table-3x3").text).toBe([ + "| column 1 | column 2 | column 3 |", + "| --- | --- | --- |", + "| | | |", + "| | | |", + "| | | |", + "", + ].join("\n")); +}); + +test("wraps selected lines as an unordered list", () => { + expect(markdownInsertion("unordered-list", "alpha\nbeta").text).toBe("- alpha\n- beta"); +}); + +test("wraps selected lines as an ordered list", () => { + expect(markdownInsertion("ordered-list", "alpha\nbeta").text).toBe("1. alpha\n2. beta"); +}); + +test("wraps selected text in a fenced code block", () => { + expect(markdownInsertion("code-block", "const x = 1;").text).toBe("```\nconst x = 1;\n```"); +}); + +test("places the empty code block cursor inside the fence", () => { + const insertion = markdownInsertion("code-block"); + + expect(insertion.text).toBe("```\n\n```"); + expect(insertion.selectionFrom).toBe(4); + expect(insertion.selectionTo).toBe(4); +}); From 1351482dce89a0253fd7428fa5f18bab3cda9bf9 Mon Sep 17 00:00:00 2001 From: Matthew Enarle <89822774+mattenarle10@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:16:59 +0800 Subject: [PATCH 06/24] feat(preview): add separate preview window (#105) --- src-tauri/capabilities/default.json | 7 ++- src/app.css | 17 ++++++ src/app.tsx | 74 +++++++++++++++++++++++- src/components/editor/index.ts | 1 + src/components/editor/preview-window.tsx | 47 +++++++++++++++ src/components/editor/preview.tsx | 44 +++++++++++--- src/locales/en.json | 3 + src/main.tsx | 6 +- src/styles/editor/panes.css | 29 ++++++++++ tests/commands.test.ts | 6 ++ 10 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 src/components/editor/preview-window.tsx diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2b80841..92e1055 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,10 +2,15 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "preview"], "permissions": [ "core:default", + "core:webview:allow-create-webview-window", "core:window:allow-start-dragging", + "core:window:allow-show", + "core:window:allow-unminimize", + "core:window:allow-set-focus", + "core:window:allow-set-title", "core:window:allow-set-fullscreen", "core:window:allow-is-fullscreen", "core:window:allow-toggle-maximize", diff --git a/src/app.css b/src/app.css index 9277df1..d0039d9 100644 --- a/src/app.css +++ b/src/app.css @@ -45,6 +45,23 @@ .mdv-app > .mdv-shell { grid-row: 3; } .mdv-app > .mdv-statusbar { grid-row: 4; } +.mdv-preview-window { + --mdv-prose-font-family: var(--font-ui); + --mdv-prose-font-size: 15px; + --mdv-prose-line-height: 1.65; + --mdv-reading-content-width: 880px; + --mdv-reading-prose-width: 720px; + height: 100vh; + width: 100vw; + background: var(--bg); + color: var(--fg); +} + +.mdv-preview-window > .mdv-preview { + height: 100%; + padding: 42px max(36px, calc((100% - var(--mdv-reading-content-width, 880px)) / 2)) 72px; +} + /* titlebar hidden mode */ .mdv-app.has-hidden-titlebar { grid-template-rows: 0 var(--breadcrumb-h) 1fr var(--statusbar-h); diff --git a/src/app.tsx b/src/app.tsx index 599b1ea..d433a4a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -21,8 +21,9 @@ import { useUpdateFlow, } from "@/hooks"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getVersion } from "@tauri-apps/api/app"; -import { listen } from "@tauri-apps/api/event"; +import { emitTo, listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { @@ -429,6 +430,73 @@ export function App() { }, [readingMode]); const copyMarkdown = useCallback(() => copyMarkdownCore(source), [copyMarkdownCore, source]); + const previewWindowTitle = useMemo(() => activePath ? basename(activePath) : "untitled", [activePath]); + const sendPreviewWindowState = useCallback(async () => { + await emitTo("preview", "marka:preview-state", { + source, + filePath: activePath, + title: previewWindowTitle, + }); + }, [activePath, previewWindowTitle, source]); + + const openPreviewWindow = useCallback(async () => { + try { + const existing = await WebviewWindow.getByLabel("preview"); + if (existing) { + await existing.show(); + await existing.unminimize(); + await existing.setFocus(); + await sendPreviewWindowState(); + return; + } + + const preview = new WebviewWindow("preview", { + url: "index.html?window=preview", + title: `${previewWindowTitle} - preview`, + width: 900, + height: 720, + minWidth: 520, + minHeight: 360, + decorations: true, + focus: true, + }); + + await new Promise((resolve, reject) => { + let settled = false; + const unlisten: Array<() => void> = []; + const settle = (next: () => void) => { + if (settled) return; + settled = true; + unlisten.forEach((fn) => fn()); + next(); + }; + + void preview.once("tauri://created", () => settle(resolve)).then((fn) => unlisten.push(fn)); + void preview.once("tauri://error", (event) => { + settle(() => reject(event.payload)); + }).then((fn) => unlisten.push(fn)); + }); + } catch (err) { + console.warn("marka.md: preview window failed", err); + showSaveAsToast(t("title.openPreviewWindowFailed")); + } + }, [previewWindowTitle, sendPreviewWindowState, showSaveAsToast, t]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + void listen("marka:preview-ready", () => { + void sendPreviewWindowState().catch(() => undefined); + }).then((fn) => { + unlisten = fn; + }); + return () => { + unlisten?.(); + }; + }, [sendPreviewWindowState]); + + useEffect(() => { + void sendPreviewWindowState().catch(() => undefined); + }, [sendPreviewWindowState]); const toggleStagedPath = useCallback((path: string) => { setStagedPaths((prev) => @@ -997,7 +1065,7 @@ export function App() {
{readingMode ? ( <> - + } - right={} + right={} /> )}
diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts index b5dc594..dbb0a5b 100644 --- a/src/components/editor/index.ts +++ b/src/components/editor/index.ts @@ -2,6 +2,7 @@ export { Editor } from "./editor"; export { CsvPreview } from "./csv-preview"; export { OpenTabs } from "./open-tabs"; export { Preview } from "./preview"; +export { PreviewWindow } from "./preview-window"; export { ReadingFind } from "./reading-find"; export { TocPanel } from "./toc-panel"; export { Splitter } from "./splitter"; diff --git a/src/components/editor/preview-window.tsx b/src/components/editor/preview-window.tsx new file mode 100644 index 0000000..5ed31a6 --- /dev/null +++ b/src/components/editor/preview-window.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { emitTo, listen } from "@tauri-apps/api/event"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { Preview } from "./preview"; + +type PreviewWindowState = { + source: string; + filePath?: string | null; + title?: string; +}; + +export function PreviewWindow() { + const [state, setState] = useState({ + source: "", + filePath: null, + title: "preview", + }); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + + void listen("marka:preview-state", (event) => { + if (cancelled) return; + setState(event.payload); + const title = event.payload.title ? `${event.payload.title} - preview` : "preview - marka.md"; + document.title = title; + void WebviewWindow.getCurrent().setTitle(title); + }).then((fn) => { + unlisten = fn; + return emitTo("main", "marka:preview-ready"); + }).catch((err) => { + console.warn("marka.md: preview window sync failed", err); + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + return ( +
+ +
+ ); +} diff --git a/src/components/editor/preview.tsx b/src/components/editor/preview.tsx index 033295a..0a34ebf 100644 --- a/src/components/editor/preview.tsx +++ b/src/components/editor/preview.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import { readFile } from "@tauri-apps/plugin-fs"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ExternalLink } from "lucide-react"; +import { Button, Icon } from "@/components/primitives"; import { ensureMarkdownReady, renderMarkdown, useI18n, useTheme } from "@/lib"; import { extensionFromMarkdownAssetSrc, markdownMediaAssetForExtension } from "@/lib/media-assets"; import inspectUrl from "@/assets/mascot/inspect.png"; @@ -21,6 +23,7 @@ import { type PreviewProps = { source: string; filePath?: string | null; + onOpenPreviewWindow?: () => void; }; // hand-written lucide copy + check icons so we don't drag in react-dom/server @@ -141,7 +144,7 @@ function decorateCodeBlocks(root: HTMLElement): () => void { return () => cleanups.forEach((fn) => fn()); } -export function Preview({ source, filePath }: PreviewProps) { +export function Preview({ source, filePath, onOpenPreviewWindow }: PreviewProps) { const theme = useTheme(); const { t } = useI18n(); const [ready, setReady] = useState(false); @@ -158,6 +161,11 @@ export function Preview({ source, filePath }: PreviewProps) { setViewer(createDiagramViewer(next)); }, []); const closeDiagramViewer = useCallback(() => setViewer(null), []); + const handleOpenPreviewWindow = useCallback((event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void onOpenPreviewWindow?.(); + }, [onOpenPreviewWindow]); useEffect(() => { let cancelled = false; @@ -170,6 +178,7 @@ export function Preview({ source, filePath }: PreviewProps) { }, []); const [html, setHtml] = useState(""); + const renderedHtml = useMemo(() => ({ __html: html }), [html]); // renderMarkdown is async (lazy-loads shiki themes + langs on demand). // Cancelled flag guards against stale renders on rapid file/theme switches. @@ -184,14 +193,10 @@ export function Preview({ source, filePath }: PreviewProps) { }; }, [source, theme, ready, csvPreview]); - // Imperatively set innerHTML — React's dangerouslySetInnerHTML re-applies the - // string on each parent re-render even when the value is unchanged, which - // wipes mermaid's post-render DOM mutations (and shiki's decorate-codeblock - // wrappers). Setting innerHTML in a useEffect that only fires when `html` - // actually changes preserves mermaid SVGs across save / saveStatus updates. + // React owns the base markdown HTML; the effects below only decorate the + // rendered DOM with media resolution, diagram viewers, and code copy buttons. useEffect(() => { if (!articleRef.current || csvPreview) return; - articleRef.current.innerHTML = html; replaceRemoteMediaImages(articleRef.current); if (filePath) void resolveMarkdownMediaAssets(articleRef.current, filePath); }, [html, filePath, csvPreview]); @@ -249,6 +254,17 @@ export function Preview({ source, filePath }: PreviewProps) { if (!csvPreview && source.trim().length === 0) { return (
+ {onOpenPreviewWindow ? ( +
+
+ ) : null}
+ {onOpenPreviewWindow ? ( +
+
+ ) : null} {csvPreview ? ( ) : ( @@ -276,6 +303,7 @@ export function Preview({ source, filePath }: PreviewProps) { ref={articleRef} className="mdv-prose" data-theme={theme} + dangerouslySetInnerHTML={renderedHtml} /> )}
diff --git a/src/locales/en.json b/src/locales/en.json index 6559008..739dd47 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -63,6 +63,9 @@ "title.exitReadingTooltip": "exit reading (esc)", "title.readingMode": "reading mode", "title.readingModeShortcut": "reading mode (⌘.)", + "title.openPreviewWindow": "open preview window", + "title.openPreviewWindowTooltip": "open preview in a separate window", + "title.openPreviewWindowFailed": "couldn't open preview window", "title.theme": "theme", "title.themeTooltip": "theme & transparency", "title.transparency": "transparency", diff --git a/src/main.tsx b/src/main.tsx index d8b4aea..fb372c4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./app"; +import { PreviewWindow } from "./components/editor"; import { I18nProvider } from "./lib"; import "./styles/globals.css"; @@ -17,10 +18,13 @@ const platformClass = /Mac|iPhone|iPad|iPod/i.test(ua) : "is-unknown"; // no platform-specific chrome applied — safe default document.documentElement.classList.add(platformClass); +const params = new URLSearchParams(window.location.search); +const Root = params.get("window") === "preview" ? PreviewWindow : App; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + , ); diff --git a/src/styles/editor/panes.css b/src/styles/editor/panes.css index 9686876..d4cb59b 100644 --- a/src/styles/editor/panes.css +++ b/src/styles/editor/panes.css @@ -289,12 +289,41 @@ /* preview pane wrapper */ .mdv-preview { + position: relative; height: 100%; overflow: auto; padding: 20px 28px 80px; user-select: text; } +.mdv-preview__floating-actions { + position: absolute; + top: 10px; + right: 10px; + z-index: 6; + pointer-events: none; +} + +.mdv-preview__float-button.mdv-btn { + width: 24px; + height: 24px; + min-width: 24px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 76%, transparent); + color: var(--muted); + opacity: 0.72; + pointer-events: auto; + backdrop-filter: blur(8px); +} + +.mdv-preview__float-button.mdv-btn:focus-visible, +.mdv-preview__float-button.mdv-btn:hover { + background: var(--bg); + color: var(--fg); + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + opacity: 1; +} + /* scrollbars — CSS standard (Firefox) */ .mdv-preview { scrollbar-width: thin; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 27c62fd..42e9c66 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -58,6 +58,12 @@ test("labels the outline command from its current visibility", () => { expect(hidden.find((command) => command.id === "toggle-toc")?.label).toBe("command.showToc"); }); +test("keeps the separate preview window out of the command palette", () => { + const commands = buildCommands(commandActions()); + + expect(commands.some((command) => command.id === "open-preview-window")).toBe(false); +}); + test("includes markdown insertion commands", () => { const commands = buildCommands(commandActions()); const ids = commands.map((command) => command.id); From f0c6d890d3b5bc54d958e7397c22ed4819e0d73f Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Sun, 12 Jul 2026 20:25:46 +0800 Subject: [PATCH 07/24] chore(release): prepare v1.6.0 --- README.md | 1 + docs/release-notes/v1.6.0.md | 22 ++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/lib/release-notes.ts | 1 + tests/release-notes.test.ts | 8 +++++++- 8 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 docs/release-notes/v1.6.0.md diff --git a/README.md b/README.md index 123f67b..b0cb1da 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Per-release detail lives on the [changelog](https://markamd.vercel.app/changelog - [x] v1.5 core loop: context tray, file tabs, CSV preview, grouped themes, interface languages, PDF/export polish, file workflow improvements, preview link handling, and scroll memory - [x] v1.5.12 polish: smoother preview scrolling, cleaner sidebar, refreshed demo doc, quickstart tutorial updates, and smarter command palette search - [x] v1.5.18 polish: playable media previews and faster tab shortcuts +- [x] v1.6.0 workflow: separate preview windows, command-palette markdown insertions, and Traditional Chinese localization - [ ] next: native/silent PDF generation - [ ] next: context handoff presets for bring-your-own-ai workflows, starting with markdown and XML-tag bundle formats diff --git a/docs/release-notes/v1.6.0.md b/docs/release-notes/v1.6.0.md new file mode 100644 index 0000000..64de9fa --- /dev/null +++ b/docs/release-notes/v1.6.0.md @@ -0,0 +1,22 @@ +# v1.6.0 + +v1.6.0 is a workflow release for people who write and review markdown side by side. + +## added + +- Added a separate live preview window for keeping rendered markdown on another display or workspace (#97). +- Added a small floating preview-pane control for opening the separate preview window without crowding the command palette or toolbar. +- Added command palette insertions for common markdown blocks: 2x2 tables, 3x3 tables, bulleted lists, numbered lists, and fenced code blocks (#98). +- Added Traditional Chinese localization (#103). + +## improved + +- Kept layout/navigation commands grouped under workspace in the command palette, so insert commands stay easier to scan. +- Updated the welcome and help overlays for the current markdown insertion workflow. +- Preserved live preview sync when the separate preview window is reopened, focused, or already running. +- Added visible failure feedback if the separate preview window cannot be created. + +## notes + +- This release closes #97 and includes the already-closed #98 markdown insertion work. +- The signed updater setup is unchanged. diff --git a/package.json b/package.json index 70d8701..7ae03b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marka-md", "private": true, - "version": "1.5.18", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f65651a..90d363a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2031,7 +2031,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "marka" -version = "1.5.18" +version = "1.6.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f18967b..da926e8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "marka" -version = "1.5.18" +version = "1.6.0" description = "marka.md — a local markdown editor for the notes you share with ai" authors = ["Matt Enarle"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8a38c7f..b06829b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "marka.md", "mainBinaryName": "marka.md", - "version": "1.5.18", + "version": "1.6.0", "identifier": "com.mattenarle.markamd", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts index d638cd9..a3cc257 100644 --- a/src/lib/release-notes.ts +++ b/src/lib/release-notes.ts @@ -2,6 +2,7 @@ export const CHANGELOG_URL = "https://markamd.vercel.app/changelog"; const WHATS_NEW_TOAST_BY_MINOR: Record = { "1.5": "Reading controls, prose fonts, theme polish, and hidden-toolbar fixes are here", + "1.6": "Separate preview windows, markdown insertions, and Traditional Chinese localization are here", }; export function getWhatsNewToastMessage(version: string): string { diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index 10b080d..6879711 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -8,5 +8,11 @@ test("calls out the latest v1.5 polish in the what's-new toast", () => { }); test("falls back to a generic update message for other versions", () => { - expect(getWhatsNewToastMessage("1.6.0")).toBe("updated to v1.6.0"); + expect(getWhatsNewToastMessage("2.0.0")).toBe("updated to v2.0.0"); +}); + +test("calls out the v1.6 workflow release in the what's-new toast", () => { + expect(getWhatsNewToastMessage("1.6.0")).toBe( + "v1.6.0: Separate preview windows, markdown insertions, and Traditional Chinese localization are here", + ); }); From 56b920cffd1ca9048a495da7b22df3f9427761ac Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Wed, 15 Jul 2026 21:25:55 +0800 Subject: [PATCH 08/24] fix(linux): disable webkit dmabuf on wayland --- src-tauri/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index adc41eb..bec8166 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -196,6 +196,9 @@ fn complete_wait_sessions(markers: Vec) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(target_os = "linux")] + configure_linux_graphics(); + if handle_wait_client() { return; } @@ -280,3 +283,15 @@ pub fn run() { } }); } + +#[cfg(target_os = "linux")] +fn configure_linux_graphics() { + // WebKitGTK can fail to initialize EGL on some Wayland + Mesa setups. + // Keep an explicit user setting intact while using the software-backed + // path as the default for the affected session type. + if std::env::var_os("XDG_SESSION_TYPE").as_deref() == Some(std::ffi::OsStr::new("wayland")) + && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() + { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } +} From c9576130b5b400beecc9f89cd3d04c11c3e70d12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:26:36 +0000 Subject: [PATCH 09/24] chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7ae03b9..23c2dfb 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.2.0", - "typescript": "~5.9.3", + "typescript": "~7.0.2", "vite": "^7.0.4" } } From bb69816cd92cfdebfd40dbafdf0a0a6e6bceaa0c Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Wed, 15 Jul 2026 18:58:10 +0800 Subject: [PATCH 10/24] fix(config): support TypeScript 7 path aliases --- bun.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++----- tsconfig.json | 3 +-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index c3fa7c2..7b55cec 100644 --- a/bun.lock +++ b/bun.lock @@ -35,11 +35,11 @@ "devDependencies": { "@tauri-apps/cli": "^2", "@types/markdown-it": "^14.1.2", - "@types/node": "^25.7.0", + "@types/node": "^26.0.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.2.0", - "typescript": "~5.9.3", + "typescript": "~7.0.2", "vite": "^7.0.4", }, }, @@ -389,7 +389,7 @@ "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], - "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -399,6 +399,46 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -669,11 +709,11 @@ "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], diff --git a/tsconfig.json b/tsconfig.json index 70e38a2..439469d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,9 +20,8 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src"], From 90dd549ffd4612f0b2edc4712724e1caaa86cc6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:34:40 +0000 Subject: [PATCH 11/24] chore(deps): bump serde_with from 3.20.0 to 3.21.0 in /src-tauri Bumps [serde_with](https://github.com/jonasbb/serde_with) from 3.20.0 to 3.21.0. - [Release notes](https://github.com/jonasbb/serde_with/releases) - [Commits](https://github.com/jonasbb/serde_with/compare/v3.20.0...v3.21.0) --- updated-dependencies: - dependency-name: serde_with dependency-version: 3.21.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src-tauri/Cargo.lock | 61 +++++++++++--------------------------------- 1 file changed, 15 insertions(+), 46 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90d363a..db7bdc7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1596,7 +1596,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -3256,9 +3256,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3276,9 +3276,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", @@ -3584,7 +3584,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -4746,7 +4746,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", + "windows-core", "windows-implement", "windows-interface", ] @@ -4770,7 +4770,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4841,7 +4841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -4853,7 +4853,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4865,21 +4865,8 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-result", + "windows-strings", ] [[package]] @@ -4888,7 +4875,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] @@ -4933,7 +4920,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -4946,15 +4933,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -4964,15 +4942,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -5395,7 +5364,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] From 1d31724829f0a00feab48c123a0e3c0c0e5897b6 Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Fri, 17 Jul 2026 22:51:10 +0800 Subject: [PATCH 12/24] feat(files): refresh open folders automatically --- src/components/files/file-tree.tsx | 3 ++ src/hooks/index.ts | 1 + src/hooks/use-directory-watcher.ts | 67 ++++++++++++++++++++++++++++++ src/lib/files.ts | 4 ++ src/lib/index.ts | 1 + tests/files.test.ts | 20 ++++++++- 6 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/hooks/use-directory-watcher.ts diff --git a/src/components/files/file-tree.tsx b/src/components/files/file-tree.tsx index a703c6b..fc3bf3f 100644 --- a/src/components/files/file-tree.tsx +++ b/src/components/files/file-tree.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { listFolder, type FileEntry } from "@/lib"; +import { useDirectoryWatcher } from "@/hooks"; import sadUrl from "@/assets/mascot/sad.png"; import { EditableRow } from "./editable-row"; import { FileNode, FolderNode } from "./folder-node"; @@ -66,6 +67,8 @@ export function FileTree({ }; }, [rootPath, treeVersion]); + useDirectoryWatcher(rootPath, setEntries); + if (error) { return (
diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 76c2f7d..60d1814 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,6 +6,7 @@ export { useNotifications } from "./use-notifications"; export { useOverlays } from "./use-overlays"; export { useUpdateFlow } from "./use-update-flow"; export { useFileWatcher } from "./use-file-watcher"; +export { useDirectoryWatcher } from "./use-directory-watcher"; export { usePersistedState } from "./use-persisted-state"; export { useShortcuts, type ShortcutHandler } from "./use-shortcuts"; export { useSyncScroll } from "./use-sync-scroll"; diff --git a/src/hooks/use-directory-watcher.ts b/src/hooks/use-directory-watcher.ts new file mode 100644 index 0000000..99e4f0b --- /dev/null +++ b/src/hooks/use-directory-watcher.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from "react"; +import { directoryFingerprint, listFolder, type FileEntry } from "@/lib"; + +const DIRECTORY_POLL_MS = 2000; + +/** Polls visible folder nodes so external additions and deletions reach the tree. */ +export function useDirectoryWatcher( + path: string, + onChange: (entries: FileEntry[]) => void, +): void { + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + let active = true; + let lastFingerprint: string | null = null; + let intervalId: number | null = null; + + const check = async () => { + if (!active) return; + try { + const entries = await listFolder(path); + if (!active) return; + const nextFingerprint = directoryFingerprint(entries); + if (lastFingerprint !== null && nextFingerprint !== lastFingerprint) { + onChangeRef.current(entries); + } + lastFingerprint = nextFingerprint; + } catch { + // The folder may be temporarily unavailable; retry on the next tick. + } + }; + + const startInterval = () => { + if (intervalId === null) { + intervalId = window.setInterval(check, DIRECTORY_POLL_MS); + } + }; + const stopInterval = () => { + if (intervalId !== null) { + window.clearInterval(intervalId); + intervalId = null; + } + }; + + const onFocus = () => { + startInterval(); + void check(); + }; + const onBlur = stopInterval; + + void check(); + startInterval(); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + + return () => { + active = false; + stopInterval(); + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + }; + }, [path]); +} diff --git a/src/lib/files.ts b/src/lib/files.ts index 330b7a3..1663322 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -86,6 +86,10 @@ export async function listFolder(path: string): Promise { }); } +export function directoryFingerprint(entries: readonly FileEntry[]): string { + return entries.map((entry) => `${entry.isDir ? "dir" : "file"}:${entry.path}`).join("\0"); +} + export type FlatFileEntry = { name: string; path: string; diff --git a/src/lib/index.ts b/src/lib/index.ts index 0db527d..b57a53b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -79,6 +79,7 @@ export { pickMarkdownFile, pickSaveMarkdown, listFolder, + directoryFingerprint, walkMarkdownFiles, walkSupportedTextFiles, readMarkdown, diff --git a/tests/files.test.ts b/tests/files.test.ts index 8eece8d..8d117bc 100644 --- a/tests/files.test.ts +++ b/tests/files.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { isVisibleTreeEntryName, relativePath } from "../src/lib/files"; +import { directoryFingerprint, isVisibleTreeEntryName, relativePath } from "../src/lib/files"; test("shows common dot-prefixed tool folders", () => { for (const name of [".agent", ".claude", ".codex", ".cursor", ".github", ".vscode"]) { @@ -23,3 +23,21 @@ test("formats relative paths only inside the selected root", () => { expect(relativePath("/notes/project-extra/brief.md", "/notes/project")).toBe("brief.md"); expect(relativePath("C:\\notes\\project\\brief.md", "C:\\notes\\project")).toBe("brief.md"); }); + +test("changes directory fingerprints when entries are added or removed", () => { + const before = [{ name: "notes", path: "/docs/notes", isDir: true }]; + const after = [ + ...before, + { name: "readme.md", path: "/docs/readme.md", isDir: false }, + ]; + + expect(directoryFingerprint(before)).not.toBe(directoryFingerprint(after)); + expect(directoryFingerprint(after)).toBe(directoryFingerprint(after)); +}); + +test("directory fingerprints include file versus folder type", () => { + const file = [{ name: "notes", path: "/docs/notes", isDir: false }]; + const folder = [{ name: "notes", path: "/docs/notes", isDir: true }]; + + expect(directoryFingerprint(file)).not.toBe(directoryFingerprint(folder)); +}); From c3ec38fcea2b251cfec8e0a8cf368db191ca7f54 Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Fri, 17 Jul 2026 23:14:53 +0800 Subject: [PATCH 13/24] refactor(files): use native tauri folder watching --- src-tauri/Cargo.lock | 102 ++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 4 ++ src/app.tsx | 2 + src/components/files/file-tree.tsx | 3 - src/hooks/index.ts | 2 +- src/hooks/use-directory-watcher.ts | 67 ------------------ src/hooks/use-folder-watcher.ts | 58 ++++++++++++++++ src/lib/files.ts | 4 -- src/lib/index.ts | 1 - tests/files.test.ts | 20 +----- tests/folder-watcher.test.ts | 14 ++++ 12 files changed, 183 insertions(+), 96 deletions(-) delete mode 100644 src/hooks/use-directory-watcher.ts create mode 100644 src/hooks/use-folder-watcher.ts create mode 100644 tests/folder-watcher.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db7bdc7..a16a882 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -975,6 +975,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1055,6 +1064,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1765,6 +1783,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1938,6 +1976,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2101,6 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2156,6 +2215,47 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", + "serde", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3774,6 +3874,8 @@ dependencies = [ "dunce", "glob", "log", + "notify", + "notify-debouncer-full", "objc2-foundation", "percent-encoding", "schemars 0.8.22", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da926e8..7e809e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-single-instance = "2" tauri-plugin-opener = "2" -tauri-plugin-fs = "2" +tauri-plugin-fs = { version = "2", features = ["watch"] } tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 92e1055..5419b61 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -52,6 +52,10 @@ "identifier": "fs:allow-read-dir", "allow": [{ "path": "**" }] }, + { + "identifier": "fs:allow-watch", + "allow": [{ "path": "**" }] + }, { "identifier": "fs:allow-write-text-file", "allow": [{ "path": "**" }] diff --git a/src/app.tsx b/src/app.tsx index d433a4a..51a5336 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -10,6 +10,7 @@ import { useDebouncedValue, useFileOps, useFileSession, + useFolderWatcher, type LoadError, useNotifications, useOverlays, @@ -269,6 +270,7 @@ export function App() { startNewBuffer, onError: setLoadError, }); + useFolderWatcher(folders, bumpTree); const { paletteOpen, diff --git a/src/components/files/file-tree.tsx b/src/components/files/file-tree.tsx index fc3bf3f..a703c6b 100644 --- a/src/components/files/file-tree.tsx +++ b/src/components/files/file-tree.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; import { listFolder, type FileEntry } from "@/lib"; -import { useDirectoryWatcher } from "@/hooks"; import sadUrl from "@/assets/mascot/sad.png"; import { EditableRow } from "./editable-row"; import { FileNode, FolderNode } from "./folder-node"; @@ -67,8 +66,6 @@ export function FileTree({ }; }, [rootPath, treeVersion]); - useDirectoryWatcher(rootPath, setEntries); - if (error) { return (
diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 60d1814..da4ece2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,7 +6,7 @@ export { useNotifications } from "./use-notifications"; export { useOverlays } from "./use-overlays"; export { useUpdateFlow } from "./use-update-flow"; export { useFileWatcher } from "./use-file-watcher"; -export { useDirectoryWatcher } from "./use-directory-watcher"; +export { isDirectoryChangeEvent, useFolderWatcher } from "./use-folder-watcher"; export { usePersistedState } from "./use-persisted-state"; export { useShortcuts, type ShortcutHandler } from "./use-shortcuts"; export { useSyncScroll } from "./use-sync-scroll"; diff --git a/src/hooks/use-directory-watcher.ts b/src/hooks/use-directory-watcher.ts deleted file mode 100644 index 99e4f0b..0000000 --- a/src/hooks/use-directory-watcher.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useRef } from "react"; -import { directoryFingerprint, listFolder, type FileEntry } from "@/lib"; - -const DIRECTORY_POLL_MS = 2000; - -/** Polls visible folder nodes so external additions and deletions reach the tree. */ -export function useDirectoryWatcher( - path: string, - onChange: (entries: FileEntry[]) => void, -): void { - const onChangeRef = useRef(onChange); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - let active = true; - let lastFingerprint: string | null = null; - let intervalId: number | null = null; - - const check = async () => { - if (!active) return; - try { - const entries = await listFolder(path); - if (!active) return; - const nextFingerprint = directoryFingerprint(entries); - if (lastFingerprint !== null && nextFingerprint !== lastFingerprint) { - onChangeRef.current(entries); - } - lastFingerprint = nextFingerprint; - } catch { - // The folder may be temporarily unavailable; retry on the next tick. - } - }; - - const startInterval = () => { - if (intervalId === null) { - intervalId = window.setInterval(check, DIRECTORY_POLL_MS); - } - }; - const stopInterval = () => { - if (intervalId !== null) { - window.clearInterval(intervalId); - intervalId = null; - } - }; - - const onFocus = () => { - startInterval(); - void check(); - }; - const onBlur = stopInterval; - - void check(); - startInterval(); - window.addEventListener("focus", onFocus); - window.addEventListener("blur", onBlur); - - return () => { - active = false; - stopInterval(); - window.removeEventListener("focus", onFocus); - window.removeEventListener("blur", onBlur); - }; - }, [path]); -} diff --git a/src/hooks/use-folder-watcher.ts b/src/hooks/use-folder-watcher.ts new file mode 100644 index 0000000..073da5e --- /dev/null +++ b/src/hooks/use-folder-watcher.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs"; + +const WATCH_DEBOUNCE_MS = 350; + +export function isDirectoryChangeEvent(event: WatchEvent): boolean { + if (event.type === "any") return true; + if (typeof event.type === "string") return false; + if ("create" in event.type || "remove" in event.type) return true; + if ("modify" in event.type) { + return event.type.modify.kind === "any" || event.type.modify.kind === "rename"; + } + return false; +} + +/** Watches each opened root recursively and refreshes the visible tree on structure changes. */ +export function useFolderWatcher(paths: readonly string[], onChange: () => void): void { + const onChangeRef = useRef(onChange); + const pathsKey = paths.join("\0"); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + let disposed = false; + const unwatchers = new Set(); + const uniquePaths = Array.from(new Set(paths.filter(Boolean))); + + const start = async () => { + for (const path of uniquePaths) { + try { + const unwatch = await watch( + path, + (event) => { + if (isDirectoryChangeEvent(event)) onChangeRef.current(); + }, + { recursive: true, delayMs: WATCH_DEBOUNCE_MS }, + ); + if (disposed) { + unwatch(); + } else { + unwatchers.add(unwatch); + } + } catch (error) { + console.warn(`marka.md: failed to watch folder ${path}`, error); + } + } + }; + + void start(); + return () => { + disposed = true; + for (const unwatch of unwatchers) unwatch(); + unwatchers.clear(); + }; + }, [pathsKey]); +} diff --git a/src/lib/files.ts b/src/lib/files.ts index 1663322..330b7a3 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -86,10 +86,6 @@ export async function listFolder(path: string): Promise { }); } -export function directoryFingerprint(entries: readonly FileEntry[]): string { - return entries.map((entry) => `${entry.isDir ? "dir" : "file"}:${entry.path}`).join("\0"); -} - export type FlatFileEntry = { name: string; path: string; diff --git a/src/lib/index.ts b/src/lib/index.ts index b57a53b..0db527d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -79,7 +79,6 @@ export { pickMarkdownFile, pickSaveMarkdown, listFolder, - directoryFingerprint, walkMarkdownFiles, walkSupportedTextFiles, readMarkdown, diff --git a/tests/files.test.ts b/tests/files.test.ts index 8d117bc..8eece8d 100644 --- a/tests/files.test.ts +++ b/tests/files.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { directoryFingerprint, isVisibleTreeEntryName, relativePath } from "../src/lib/files"; +import { isVisibleTreeEntryName, relativePath } from "../src/lib/files"; test("shows common dot-prefixed tool folders", () => { for (const name of [".agent", ".claude", ".codex", ".cursor", ".github", ".vscode"]) { @@ -23,21 +23,3 @@ test("formats relative paths only inside the selected root", () => { expect(relativePath("/notes/project-extra/brief.md", "/notes/project")).toBe("brief.md"); expect(relativePath("C:\\notes\\project\\brief.md", "C:\\notes\\project")).toBe("brief.md"); }); - -test("changes directory fingerprints when entries are added or removed", () => { - const before = [{ name: "notes", path: "/docs/notes", isDir: true }]; - const after = [ - ...before, - { name: "readme.md", path: "/docs/readme.md", isDir: false }, - ]; - - expect(directoryFingerprint(before)).not.toBe(directoryFingerprint(after)); - expect(directoryFingerprint(after)).toBe(directoryFingerprint(after)); -}); - -test("directory fingerprints include file versus folder type", () => { - const file = [{ name: "notes", path: "/docs/notes", isDir: false }]; - const folder = [{ name: "notes", path: "/docs/notes", isDir: true }]; - - expect(directoryFingerprint(file)).not.toBe(directoryFingerprint(folder)); -}); diff --git a/tests/folder-watcher.test.ts b/tests/folder-watcher.test.ts new file mode 100644 index 0000000..e7486f4 --- /dev/null +++ b/tests/folder-watcher.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { isDirectoryChangeEvent } from "../src/hooks/use-folder-watcher"; + +test("refreshes the tree for folder creation, removal, and rename events", () => { + expect(isDirectoryChangeEvent({ type: { create: { kind: "folder" } }, paths: [], attrs: null })).toBe(true); + expect(isDirectoryChangeEvent({ type: { remove: { kind: "file" } }, paths: [], attrs: null })).toBe(true); + expect(isDirectoryChangeEvent({ type: { modify: { kind: "rename", mode: "both" } }, paths: [], attrs: null })).toBe(true); +}); + +test("ignores file content and access events for tree refreshes", () => { + expect(isDirectoryChangeEvent({ type: { modify: { kind: "data", mode: "content" } }, paths: [], attrs: null })).toBe(false); + expect(isDirectoryChangeEvent({ type: { access: { kind: "open", mode: "read" } }, paths: [], attrs: null })).toBe(false); + expect(isDirectoryChangeEvent({ type: "other", paths: [], attrs: null })).toBe(false); +}); From 07bea38352a6ed560f581f5b6fe16de7b6afe442 Mon Sep 17 00:00:00 2001 From: Matthew Enarle <89822774+mattenarle10@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:35:22 +0800 Subject: [PATCH 14/24] perf: lazy-load markdown rendering and native file watching (#112) * feat(files): refresh open folders automatically * refactor(files): use native tauri folder watching * perf: lazy-load markdown rendering and native file watching --- src/components/editor/preview.tsx | 19 ++----- src/hooks/use-file-watcher.ts | 86 +++++++++++-------------------- src/lib/markdown.ts | 19 ++++--- tests/file-watcher.test.ts | 14 +++++ 4 files changed, 61 insertions(+), 77 deletions(-) create mode 100644 tests/file-watcher.test.ts diff --git a/src/components/editor/preview.tsx b/src/components/editor/preview.tsx index 0a34ebf..bec547b 100644 --- a/src/components/editor/preview.tsx +++ b/src/components/editor/preview.tsx @@ -3,7 +3,7 @@ import { readFile } from "@tauri-apps/plugin-fs"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ExternalLink } from "lucide-react"; import { Button, Icon } from "@/components/primitives"; -import { ensureMarkdownReady, renderMarkdown, useI18n, useTheme } from "@/lib"; +import { renderMarkdown, useI18n, useTheme } from "@/lib"; import { extensionFromMarkdownAssetSrc, markdownMediaAssetForExtension } from "@/lib/media-assets"; import inspectUrl from "@/assets/mascot/inspect.png"; import { renderMermaidBlocks } from "@/lib/mermaid"; @@ -147,7 +147,6 @@ function decorateCodeBlocks(root: HTMLElement): () => void { export function Preview({ source, filePath, onOpenPreviewWindow }: PreviewProps) { const theme = useTheme(); const { t } = useI18n(); - const [ready, setReady] = useState(false); const [viewer, setViewer] = useState(null); const articleRef = useRef(null); const previewRef = useRef(null); @@ -167,23 +166,13 @@ export function Preview({ source, filePath, onOpenPreviewWindow }: PreviewProps) void onOpenPreviewWindow?.(); }, [onOpenPreviewWindow]); - useEffect(() => { - let cancelled = false; - void ensureMarkdownReady().then(() => { - if (!cancelled) setReady(true); - }); - return () => { - cancelled = true; - }; - }, []); - const [html, setHtml] = useState(""); const renderedHtml = useMemo(() => ({ __html: html }), [html]); - // renderMarkdown is async (lazy-loads shiki themes + langs on demand). + // renderMarkdown lazy-loads shiki only when the document contains code. // Cancelled flag guards against stale renders on rapid file/theme switches. useEffect(() => { - if (!ready || csvPreview) return; + if (csvPreview) return; let cancelled = false; void renderMarkdown(source, theme).then((h) => { if (!cancelled) setHtml(h); @@ -191,7 +180,7 @@ export function Preview({ source, filePath, onOpenPreviewWindow }: PreviewProps) return () => { cancelled = true; }; - }, [source, theme, ready, csvPreview]); + }, [source, theme, csvPreview]); // React owns the base markdown HTML; the effects below only decorate the // rendered DOM with media resolution, diagram viewers, and code copy buttons. diff --git a/src/hooks/use-file-watcher.ts b/src/hooks/use-file-watcher.ts index d39c573..14a2db1 100644 --- a/src/hooks/use-file-watcher.ts +++ b/src/hooks/use-file-watcher.ts @@ -1,68 +1,44 @@ import { useEffect } from "react"; -import { stat } from "@tauri-apps/plugin-fs"; +import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs"; + +export function isFileContentChangeEvent(event: WatchEvent): boolean { + if (event.type === "any") return true; + if (typeof event.type === "string") return false; + if ("create" in event.type || "remove" in event.type) return true; + if ("modify" in event.type) { + return event.type.modify.kind === "any" || event.type.modify.kind === "data"; + } + return false; +} /** - * Polls mtime every 2s. Pauses on blur, resumes on focus. - * Picked over tauri-plugin-fs-watch: md files are cheap, no new rust deps. + * Watches the active file through Tauri's native filesystem watcher. */ export function useFileWatcher(path: string | null, onChange: () => void): void { useEffect(() => { if (!path) return; - let lastMtime: number | null = null; - let active = true; - let intervalId: number | null = null; - - const check = async () => { - if (!active) return; - try { - const meta = await stat(path); - const m = meta.mtime ? new Date(meta.mtime).getTime() : null; - // first read seeds lastMtime without firing onChange — only later ticks - // count as "external changes". - if (lastMtime !== null && m !== null && m !== lastMtime) { - onChange(); - } - lastMtime = m; - } catch { - // file deleted / unreadable — stop polling cleanly, no error spam - active = false; - if (intervalId !== null) { - window.clearInterval(intervalId); - intervalId = null; - } - } - }; - - const startInterval = () => { - if (intervalId === null) { - intervalId = window.setInterval(check, 2000); - } - }; - const stopInterval = () => { - if (intervalId !== null) { - window.clearInterval(intervalId); - intervalId = null; - } - }; - - const onFocus = () => { - startInterval(); - void check(); - }; - const onBlur = () => { - stopInterval(); - }; + let disposed = false; + let unwatch: UnwatchFn | null = null; - void check(); // seed lastMtime - startInterval(); - window.addEventListener("focus", onFocus); - window.addEventListener("blur", onBlur); + void watch( + path, + (event) => { + if (isFileContentChangeEvent(event)) onChange(); + }, + { delayMs: 350 }, + ) + .then((stop) => { + if (disposed) stop(); + else unwatch = stop; + }) + .catch((error) => { + console.warn(`marka.md: failed to watch file ${path}`, error); + }); return () => { - active = false; - stopInterval(); - window.removeEventListener("focus", onFocus); - window.removeEventListener("blur", onBlur); + disposed = true; + unwatch?.(); + unwatch = null; }; }, [path, onChange]); } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 0c72503..2d990d4 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,7 +1,7 @@ import MarkdownIt from "markdown-it"; import mark from "markdown-it-mark"; import taskLists from "markdown-it-task-lists"; -import { createHighlighter, type Highlighter } from "shiki"; +import type { Highlighter } from "shiki"; import { plantUmlUrl } from "./plantuml"; import type { Theme } from "./theme"; @@ -60,7 +60,8 @@ let activeShikiTheme: string = THEMES.latte; function getHighlighter(): Promise { if (!highlighterPromise) { - highlighterPromise = createHighlighter({ themes: [], langs: [] }) + highlighterPromise = import("shiki") + .then(({ createHighlighter }) => createHighlighter({ themes: [], langs: [] })) .then((h) => { highlighter = h; return h; @@ -165,14 +166,18 @@ md.renderer.rules.heading_open = (tokens, idx, options, _env, self) => { }; export async function ensureMarkdownReady(): Promise { + // Kept for callers that explicitly want to warm the renderer. await getHighlighter(); } export async function renderMarkdown(src: string, theme: Theme): Promise { - const h = await getHighlighter(); - const shikiTheme = THEMES[theme]; - await ensureThemeLoaded(h, shikiTheme); - await ensureLangsLoaded(h, extractLangs(src)); - activeShikiTheme = shikiTheme; + const langs = extractLangs(src); + if (langs.length > 0) { + const h = await getHighlighter(); + const shikiTheme = THEMES[theme]; + await ensureThemeLoaded(h, shikiTheme); + await ensureLangsLoaded(h, langs); + activeShikiTheme = shikiTheme; + } return md.render(src); } diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts new file mode 100644 index 0000000..49b688c --- /dev/null +++ b/tests/file-watcher.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { isFileContentChangeEvent } from "../src/hooks/use-file-watcher"; + +test("reloads for file create, remove, and content changes", () => { + expect(isFileContentChangeEvent({ type: { create: { kind: "file" } }, paths: [], attrs: null })).toBe(true); + expect(isFileContentChangeEvent({ type: { remove: { kind: "file" } }, paths: [], attrs: null })).toBe(true); + expect(isFileContentChangeEvent({ type: { modify: { kind: "data", mode: "content" } }, paths: [], attrs: null })).toBe(true); +}); + +test("ignores access, metadata, and unrelated events", () => { + expect(isFileContentChangeEvent({ type: { access: { kind: "open", mode: "read" } }, paths: [], attrs: null })).toBe(false); + expect(isFileContentChangeEvent({ type: { modify: { kind: "metadata", mode: "permissions" } }, paths: [], attrs: null })).toBe(false); + expect(isFileContentChangeEvent({ type: "other", paths: [], attrs: null })).toBe(false); +}); From 983e61998d8976a50967d7c2404a19f9d3afcd60 Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Fri, 17 Jul 2026 23:36:53 +0800 Subject: [PATCH 15/24] chore(release): prepare v1.6.1 --- docs/release-notes/v1.6.1.md | 17 +++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v1.6.1.md diff --git a/docs/release-notes/v1.6.1.md b/docs/release-notes/v1.6.1.md new file mode 100644 index 0000000..13c0b4c --- /dev/null +++ b/docs/release-notes/v1.6.1.md @@ -0,0 +1,17 @@ +# v1.6.1 + +v1.6.1 is a small workflow and performance release for working with markdown projects. + +## added + +- Added automatic folder monitoring so open project trees refresh when files or folders are created, removed, or renamed (#110). + +## improved + +- Replaced active-file polling with native filesystem watching for faster external edit detection and lower idle work. +- Lazy-loaded Markdown syntax highlighting so documents without code blocks start with less work. +- Ignored access and metadata-only filesystem events when refreshing the active document. + +## notes + +- The signed updater setup is unchanged. diff --git a/package.json b/package.json index 23c2dfb..b7ff88c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marka-md", "private": true, - "version": "1.6.0", + "version": "1.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a16a882..409b518 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2089,7 +2089,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "marka" -version = "1.6.0" +version = "1.6.1" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7e809e3..b839d64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "marka" -version = "1.6.0" +version = "1.6.1" description = "marka.md — a local markdown editor for the notes you share with ai" authors = ["Matt Enarle"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b06829b..c766bda 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "marka.md", "mainBinaryName": "marka.md", - "version": "1.6.0", + "version": "1.6.1", "identifier": "com.mattenarle.markamd", "build": { "beforeDevCommand": "bun run dev", From 4e09f94ab06007a278f5a32109ec9a5f3b6420ce Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Sun, 19 Jul 2026 16:13:38 +0800 Subject: [PATCH 16/24] docs: mark v1.6.1 roadmap complete --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b0cb1da..7868215 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Per-release detail lives on the [changelog](https://markamd.vercel.app/changelog - [x] v1.5.12 polish: smoother preview scrolling, cleaner sidebar, refreshed demo doc, quickstart tutorial updates, and smarter command palette search - [x] v1.5.18 polish: playable media previews and faster tab shortcuts - [x] v1.6.0 workflow: separate preview windows, command-palette markdown insertions, and Traditional Chinese localization +- [x] v1.6.1 workflow: automatic folder monitoring, native file watching, and lazy markdown highlighting - [ ] next: native/silent PDF generation - [ ] next: context handoff presets for bring-your-own-ai workflows, starting with markdown and XML-tag bundle formats From 6909ab782b19c2133e1c59db42e17e09430d8a30 Mon Sep 17 00:00:00 2001 From: Icatme Date: Thu, 23 Jul 2026 23:28:46 +0800 Subject: [PATCH 17/24] fix drive-root startup hang (#119) --- src/app.tsx | 7 +++- src/hooks/use-folder-watcher.ts | 9 ++++- src/lib/index.ts | 7 +++- src/lib/storage.ts | 43 +++++++++++++++++++++++ src/locales/de.json | 1 + src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/fr.json | 1 + src/locales/it.json | 1 + src/locales/ja.json | 1 + src/locales/ko.json | 1 + src/locales/pt-BR.json | 1 + src/locales/zh-TW.json | 1 + src/locales/zh.json | 1 + src/main.tsx | 6 +++- tests/folder-watcher.test.ts | 11 +++++- tests/storage.test.ts | 60 +++++++++++++++++++++++++++++++++ 17 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 tests/storage.test.ts diff --git a/src/app.tsx b/src/app.tsx index 51a5336..52eb6fa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -38,6 +38,7 @@ import { getWritingDisplayVars, getContextBundleStats, getWhatsNewToastMessage, + isFilesystemRoot, isSupportedTextPath, markdownInsertion, normalizeProseFontFamily, @@ -628,9 +629,13 @@ export function App() { const handleOpenFolder = useCallback(async () => { const folder = await pickFolder(); if (!folder) return; + if (isFilesystemRoot(folder)) { + setLoadError({ message: t("app.folderRootUnsupported") }); + return; + } setFolders((prev) => (prev.includes(folder) ? prev : [...prev, folder])); setSidebarOpen(true); - }, [setFolders, setSidebarOpen]); + }, [setFolders, setLoadError, setSidebarOpen, t]); const handleOpenFile = useCallback(async () => { const file = await pickMarkdownFile(); diff --git a/src/hooks/use-folder-watcher.ts b/src/hooks/use-folder-watcher.ts index 073da5e..45efe4c 100644 --- a/src/hooks/use-folder-watcher.ts +++ b/src/hooks/use-folder-watcher.ts @@ -1,8 +1,15 @@ import { useEffect, useRef } from "react"; import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs"; +import { isFilesystemRoot } from "@/lib/storage"; const WATCH_DEBOUNCE_MS = 350; +export function watchableFolderPaths(paths: readonly string[]): string[] { + return Array.from(new Set( + paths.filter((path) => path.length > 0 && !isFilesystemRoot(path)), + )); +} + export function isDirectoryChangeEvent(event: WatchEvent): boolean { if (event.type === "any") return true; if (typeof event.type === "string") return false; @@ -25,7 +32,7 @@ export function useFolderWatcher(paths: readonly string[], onChange: () => void) useEffect(() => { let disposed = false; const unwatchers = new Set(); - const uniquePaths = Array.from(new Set(paths.filter(Boolean))); + const uniquePaths = watchableFolderPaths(paths); const start = async () => { for (const path of uniquePaths) { diff --git a/src/lib/index.ts b/src/lib/index.ts index 0db527d..93863b7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -21,7 +21,12 @@ export { type ThemeGroup, type ThemeMode, } from "./theme"; -export { STORAGE_KEYS, type StorageKey } from "./storage"; +export { + clearUnsafeFolderRestoreState, + isFilesystemRoot, + STORAGE_KEYS, + type StorageKey, +} from "./storage"; export { I18nProvider, LANGUAGE_CHOICES, diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 23e8b5c..17b9e60 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -24,3 +24,46 @@ export const STORAGE_KEYS = { } as const; export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; + +type FolderSessionStorage = Pick; + +export function isFilesystemRoot(path: string): boolean { + const normalized = path.trim(); + return /^[/\\]+$/.test(normalized) || /^[A-Za-z]:[/\\]*$/.test(normalized); +} + +/** + * Removes a poisoned folder session before React restores either the current + * multi-folder state or its legacy single-folder fallback. + */ +export function clearUnsafeFolderRestoreState(storage: FolderSessionStorage): boolean { + let folders: unknown; + let lastFolder: unknown; + try { + const foldersRaw = storage.getItem(STORAGE_KEYS.folders); + const lastFolderRaw = storage.getItem(STORAGE_KEYS.lastFolder); + folders = foldersRaw == null ? null : JSON.parse(foldersRaw); + lastFolder = lastFolderRaw == null ? null : JSON.parse(lastFolderRaw); + } catch { + return false; + } + + const hasUnsafeFolder = Array.isArray(folders) + && folders.some((path) => typeof path === "string" && isFilesystemRoot(path)); + const hasUnsafeFallback = typeof lastFolder === "string" && isFilesystemRoot(lastFolder); + if (!hasUnsafeFolder && !hasUnsafeFallback) return false; + + // These keys fall back to each other during hydration, so they must be + // removed together or the drive root will be restored again. + try { + storage.removeItem(STORAGE_KEYS.folders); + } catch { + // Continue so the fallback key is still cleared. + } + try { + storage.removeItem(STORAGE_KEYS.lastFolder); + } catch { + // Storage failures are non-fatal; the watcher guard remains authoritative. + } + return true; +} diff --git a/src/locales/de.json b/src/locales/de.json index a07d91f..a2f48d4 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -9,6 +9,7 @@ "app.openFile": "Datei öffnen", "app.openFileShortcut": "Datei öffnen (⌘O)", "app.openFolder": "Ordner öffnen", + "app.folderRootUnsupported": "Wähle einen Unterordner; Dateisystem-Stammverzeichnisse können nicht geöffnet werden", "app.openFolderShortcut": "Ordner öffnen (⌘⇧O)", "app.close": "schließen", "app.closeEsc": "schließen (esc)", diff --git a/src/locales/en.json b/src/locales/en.json index 739dd47..496f85b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -9,6 +9,7 @@ "app.openFile": "open file", "app.openFileShortcut": "open file (⌘O)", "app.openFolder": "open folder", + "app.folderRootUnsupported": "choose a subfolder; filesystem roots cannot be opened", "app.openFolderShortcut": "open folder (⌘⇧O)", "app.close": "close", "app.closeEsc": "close (esc)", diff --git a/src/locales/es.json b/src/locales/es.json index 5481d11..66b00f6 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -9,6 +9,7 @@ "app.openFile": "abrir archivo", "app.openFileShortcut": "abrir archivo (⌘O)", "app.openFolder": "abrir carpeta", + "app.folderRootUnsupported": "elige una subcarpeta; no se puede abrir la raíz del sistema de archivos", "app.openFolderShortcut": "abrir carpeta (⌘⇧O)", "app.close": "cerrar", "app.closeEsc": "cerrar (esc)", diff --git a/src/locales/fr.json b/src/locales/fr.json index c7510a7..82a952e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -9,6 +9,7 @@ "app.openFile": "ouvrir un fichier", "app.openFileShortcut": "ouvrir un fichier (⌘O)", "app.openFolder": "ouvrir un dossier", + "app.folderRootUnsupported": "choisissez un sous-dossier ; la racine du système de fichiers ne peut pas être ouverte", "app.openFolderShortcut": "ouvrir un dossier (⌘⇧O)", "app.close": "fermer", "app.closeEsc": "fermer (esc)", diff --git a/src/locales/it.json b/src/locales/it.json index 0796eb4..91bc5f4 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -9,6 +9,7 @@ "app.openFile": "apri file", "app.openFileShortcut": "apri file (⌘O)", "app.openFolder": "apri cartella", + "app.folderRootUnsupported": "scegli una sottocartella; non è possibile aprire la radice del file system", "app.openFolderShortcut": "apri cartella (⌘⇧O)", "app.close": "chiudi", "app.closeEsc": "chiudi (esc)", diff --git a/src/locales/ja.json b/src/locales/ja.json index cd933ff..7b2633e 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -9,6 +9,7 @@ "app.openFile": "ファイルを開く", "app.openFileShortcut": "ファイルを開く (⌘O)", "app.openFolder": "フォルダを開く", + "app.folderRootUnsupported": "サブフォルダを選択してください。ファイルシステムのルートは開けません", "app.openFolderShortcut": "フォルダを開く (⌘⇧O)", "app.close": "閉じる", "app.closeEsc": "閉じる (esc)", diff --git a/src/locales/ko.json b/src/locales/ko.json index 32fc562..0676eb9 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -9,6 +9,7 @@ "app.openFile": "파일 열기", "app.openFileShortcut": "파일 열기 (⌘O)", "app.openFolder": "폴더 열기", + "app.folderRootUnsupported": "하위 폴더를 선택하세요. 파일 시스템 루트는 열 수 없습니다", "app.openFolderShortcut": "폴더 열기 (⌘⇧O)", "app.close": "닫기", "app.closeEsc": "닫기 (esc)", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index c2f8689..d6211fd 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -9,6 +9,7 @@ "app.openFile": "abrir arquivo", "app.openFileShortcut": "abrir arquivo (⌘O)", "app.openFolder": "abrir pasta", + "app.folderRootUnsupported": "escolha uma subpasta; não é possível abrir a raiz do sistema de arquivos", "app.openFolderShortcut": "abrir pasta (⌘⇧O)", "app.close": "fechar", "app.closeEsc": "fechar (esc)", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 60f333f..abe0819 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -9,6 +9,7 @@ "app.openFile": "開啟檔案", "app.openFileShortcut": "開啟檔案(⌘O)", "app.openFolder": "開啟資料夾", + "app.folderRootUnsupported": "請選擇一個子資料夾,無法直接開啟檔案系統根目錄", "app.openFolderShortcut": "開啟資料夾(⌘⇧O)", "app.close": "關閉", "app.closeEsc": "關閉(Esc)", diff --git a/src/locales/zh.json b/src/locales/zh.json index bbd6aaa..9b74935 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -9,6 +9,7 @@ "app.openFile": "打开文件", "app.openFileShortcut": "打开文件 (⌘O)", "app.openFolder": "打开文件夹", + "app.folderRootUnsupported": "请选择一个子文件夹,不能直接打开文件系统根目录", "app.openFolderShortcut": "打开文件夹 (⌘⇧O)", "app.close": "关闭", "app.closeEsc": "关闭 (esc)", diff --git a/src/main.tsx b/src/main.tsx index fb372c4..c43b7a7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./app"; import { PreviewWindow } from "./components/editor"; -import { I18nProvider } from "./lib"; +import { clearUnsafeFolderRestoreState, I18nProvider } from "./lib"; import "./styles/globals.css"; // platform class on — lets CSS gate macOS-only chrome (traffic-light @@ -18,6 +18,10 @@ const platformClass = /Mac|iPhone|iPad|iPod/i.test(ua) : "is-unknown"; // no platform-specific chrome applied — safe default document.documentElement.classList.add(platformClass); +// Run before any persisted-state hooks mount. A drive root in either folder +// key otherwise falls back through the other key and starts a recursive watch. +clearUnsafeFolderRestoreState(window.localStorage); + const params = new URLSearchParams(window.location.search); const Root = params.get("window") === "preview" ? PreviewWindow : App; diff --git a/tests/folder-watcher.test.ts b/tests/folder-watcher.test.ts index e7486f4..2c8c468 100644 --- a/tests/folder-watcher.test.ts +++ b/tests/folder-watcher.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { isDirectoryChangeEvent } from "../src/hooks/use-folder-watcher"; +import { + isDirectoryChangeEvent, + watchableFolderPaths, +} from "../src/hooks/use-folder-watcher"; + +test("does not recursively watch filesystem roots", () => { + expect(watchableFolderPaths(["V:\\", "V:\\notes", "V:\\notes", "/"])).toEqual([ + "V:\\notes", + ]); +}); test("refreshes the tree for folder creation, removal, and rename events", () => { expect(isDirectoryChangeEvent({ type: { create: { kind: "folder" } }, paths: [], attrs: null })).toBe(true); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..00962a5 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { + clearUnsafeFolderRestoreState, + isFilesystemRoot, + STORAGE_KEYS, +} from "../src/lib/storage"; + +class MemoryStorage { + readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +test("recognizes filesystem roots without rejecting normal folders", () => { + expect(isFilesystemRoot("V:\\")).toBe(true); + expect(isFilesystemRoot("v:/")).toBe(true); + expect(isFilesystemRoot("/")).toBe(true); + expect(isFilesystemRoot("V:\\notes")).toBe(false); + expect(isFilesystemRoot("/Users/notes")).toBe(false); +}); + +test("clears both folder keys when the folders list contains a drive root", () => { + const storage = new MemoryStorage(); + storage.values.set(STORAGE_KEYS.folders, JSON.stringify(["V:\\"])); + storage.values.set(STORAGE_KEYS.lastFolder, JSON.stringify("V:\\notes")); + storage.values.set(STORAGE_KEYS.lastFile, JSON.stringify("V:\\diagram.md")); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(true); + expect(storage.getItem(STORAGE_KEYS.folders)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFile)).toBe(JSON.stringify("V:\\diagram.md")); +}); + +test("clears both folder keys when only the legacy fallback contains a root", () => { + const storage = new MemoryStorage(); + storage.values.set(STORAGE_KEYS.folders, JSON.stringify(["V:\\notes"])); + storage.values.set(STORAGE_KEYS.lastFolder, JSON.stringify("V:\\")); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(true); + expect(storage.getItem(STORAGE_KEYS.folders)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBeNull(); +}); + +test("keeps a safe folder session unchanged", () => { + const storage = new MemoryStorage(); + const folders = JSON.stringify(["V:\\notes"]); + const lastFolder = JSON.stringify("V:\\notes"); + storage.values.set(STORAGE_KEYS.folders, folders); + storage.values.set(STORAGE_KEYS.lastFolder, lastFolder); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(false); + expect(storage.getItem(STORAGE_KEYS.folders)).toBe(folders); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBe(lastFolder); +}); \ No newline at end of file From 9e0088ed3cc54c8a9e6ba6533833962dc3e32ec9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:21 +0800 Subject: [PATCH 18/24] chore(deps): bump tauri-plugin-single-instance in /src-tauri (#113) Bumps [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) from 2.4.2 to 2.4.3. - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.2...fs-v2.4.3) --- updated-dependencies: - dependency-name: tauri-plugin-single-instance dependency-version: 2.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src-tauri/Cargo.lock | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 409b518..d604253 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3924,14 +3924,15 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" dependencies = [ "serde", "serde_json", "tauri", "thiserror 2.0.18", + "tokio", "tracing", "windows-sys 0.60.2", "zbus", From fa74bdc120d817420d81cf2029895edb6e8a67f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:27 +0800 Subject: [PATCH 19/24] chore(deps): bump serde_json from 1.0.150 to 1.0.151 in /src-tauri (#114) Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.150 to 1.0.151. - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151) --- updated-dependencies: - dependency-name: serde_json dependency-version: 1.0.151 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d604253..40f5843 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3314,9 +3314,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", From 2d681cc2bb98d5c51d896f7ff072580888ed0ac3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:33 +0800 Subject: [PATCH 20/24] chore(deps): bump window-vibrancy from 0.7.1 to 0.8.0 in /src-tauri (#115) Bumps [window-vibrancy](https://github.com/tauri-apps/tauri-plugin-vibrancy) from 0.7.1 to 0.8.0. - [Release notes](https://github.com/tauri-apps/tauri-plugin-vibrancy/releases) - [Changelog](https://github.com/tauri-apps/window-vibrancy/blob/dev/CHANGELOG.md) - [Commits](https://github.com/tauri-apps/tauri-plugin-vibrancy/compare/window-vibrancy-v0.7.1...window-vibrancy-v0.8.0) --- updated-dependencies: - dependency-name: window-vibrancy dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src-tauri/Cargo.lock | 8 +++++--- src-tauri/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 40f5843..202f79c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2101,7 +2101,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", - "window-vibrancy 0.7.1", + "window-vibrancy 0.8.0", ] [[package]] @@ -2314,6 +2314,7 @@ dependencies = [ "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -4924,14 +4925,15 @@ dependencies = [ [[package]] name = "window-vibrancy" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" +checksum = "fe6461139557c2245c3b9b34de2092e7af39460bf97ef4723bc5feb0cf66f831" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", "windows-sys 0.60.2", "windows-version", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b839d64..afe0674 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" [target.'cfg(target_os = "macos")'.dependencies] -window-vibrancy = "0.7" +window-vibrancy = "0.8" From b507c69d21fe3b07c023c97ca4bc11e7d95b75aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:39 +0800 Subject: [PATCH 21/24] chore(deps): bump tauri-plugin-dialog from 2.7.1 to 2.7.2 in /src-tauri (#116) Bumps [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) from 2.7.1 to 2.7.2. - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.7.1...dialog-v2.7.2) --- updated-dependencies: - dependency-name: tauri-plugin-dialog dependency-version: 2.7.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 202f79c..492b917 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3849,9 +3849,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", From 4930d7a50062357057d8d15e2a84ca4592aa53b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:45 +0800 Subject: [PATCH 22/24] chore(deps): bump serde from 1.0.228 to 1.0.229 in /src-tauri (#117) Bumps [serde](https://github.com/serde-rs/serde) from 1.0.228 to 1.0.229. - [Release notes](https://github.com/serde-rs/serde/releases) - [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229) --- updated-dependencies: - dependency-name: serde dependency-version: 1.0.229 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src-tauri/Cargo.lock | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 492b917..cfd69c1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3262,9 +3262,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3284,22 +3284,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.2", ] [[package]] @@ -3617,6 +3617,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" From 65bafffa9de77759d5336fe83e5c0006dca00b19 Mon Sep 17 00:00:00 2001 From: mattenarle10 Date: Thu, 23 Jul 2026 23:31:43 +0800 Subject: [PATCH 23/24] chore(release): prepare v1.6.2 --- docs/release-notes/v1.6.2.md | 25 +++++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v1.6.2.md diff --git a/docs/release-notes/v1.6.2.md b/docs/release-notes/v1.6.2.md new file mode 100644 index 0000000..b6a4aee --- /dev/null +++ b/docs/release-notes/v1.6.2.md @@ -0,0 +1,25 @@ +# v1.6.2 + +v1.6.2 is a stability patch for Windows startup and the native filesystem workflow. + +## fixed + +- Prevented startup hangs when a persisted folder session contains a filesystem root such as `V:\` (#118). +- Rejected filesystem roots as folder selections and skipped recursive watchers for unsafe restored paths. +- Cleared both folder-restore keys when an unsafe root is found, while preserving unrelated settings. + +## updated + +- Updated `serde` to 1.0.229. +- Updated `serde_json` to 1.0.151. +- Updated `tauri-plugin-dialog` to 2.7.2. +- Updated `tauri-plugin-single-instance` to 2.4.3. +- Updated `window-vibrancy` to 0.8.0. + +## thanks + +- Thanks to [@Icatme](https://github.com/Icatme) for the Windows reproduction, diagnosis, fix, and regression tests. + +## notes + +- The signed updater setup is unchanged. diff --git a/package.json b/package.json index b7ff88c..f9fb251 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marka-md", "private": true, - "version": "1.6.1", + "version": "1.6.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfd69c1..406eb21 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2089,7 +2089,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "marka" -version = "1.6.1" +version = "1.6.2" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index afe0674..0ab7314 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "marka" -version = "1.6.1" +version = "1.6.2" description = "marka.md — a local markdown editor for the notes you share with ai" authors = ["Matt Enarle"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c766bda..10feba3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "marka.md", "mainBinaryName": "marka.md", - "version": "1.6.1", + "version": "1.6.2", "identifier": "com.mattenarle.markamd", "build": { "beforeDevCommand": "bun run dev", From 9989ac098238ee53e4d19245e55171073cb5ee5c Mon Sep 17 00:00:00 2001 From: wangzhigang Date: Fri, 24 Jul 2026 17:50:13 +0800 Subject: [PATCH 24/24] feat(preview): add quick file preview overlay for non-markdown files Clicking a non-markdown file in the sidebar now opens an in-app preview instead of the "open in default app" toast. Zero new dependencies. - images/video/audio: native renderers via blob url - pdf: webview-native iframe via data url - text/code: read-only
 with "open as text" to edit
- office/unknown formats: info card + "open in default app"

Triggers: click, context menu "quick preview", and Space (Finder Quick
Look) on a focused sidebar row. .md/.csv keep opening in the editor.
macOS toolbar left-padding clears the native traffic-light buttons.

Co-Authored-By: Claude Fable 5 
---
 src/app.css                                   |   1 +
 src/app.tsx                                   | 117 +++--
 src/components/files/folder-node.tsx          |   1 +
 .../overlays/file-preview-overlay.tsx         | 415 ++++++++++++++++++
 src/components/overlays/index.ts              |   1 +
 src/lib/index.ts                              |   1 +
 src/lib/preview.ts                            | 184 ++++++++
 src/locales/de.json                           |  12 +
 src/locales/en.json                           |  12 +
 src/locales/es.json                           |  12 +
 src/locales/fr.json                           |  12 +
 src/locales/it.json                           |  12 +
 src/locales/ja.json                           |  12 +
 src/locales/ko.json                           |  12 +
 src/locales/pt-BR.json                        |  12 +
 src/locales/zh.json                           |  12 +
 src/styles/overlays/file-preview.css          | 333 ++++++++++++++
 tests/preview.test.ts                         |  54 +++
 18 files changed, 1184 insertions(+), 31 deletions(-)
 create mode 100644 src/components/overlays/file-preview-overlay.tsx
 create mode 100644 src/lib/preview.ts
 create mode 100644 src/styles/overlays/file-preview.css
 create mode 100644 tests/preview.test.ts

diff --git a/src/app.css b/src/app.css
index d0039d9..918266d 100644
--- a/src/app.css
+++ b/src/app.css
@@ -16,6 +16,7 @@
 @import "./styles/overlays/welcome.css";
 @import "./styles/overlays/drop.css";
 @import "./styles/overlays/about.css";
+@import "./styles/overlays/file-preview.css";
 
 /* shell layout */
 .mdv-app {
diff --git a/src/app.tsx b/src/app.tsx
index 52eb6fa..90203b7 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -3,7 +3,7 @@ import type { EditorView } from "@codemirror/view";
 import { Breadcrumb, StatusBar, TitleBar, type VimMode } from "@/components/chrome";
 import { Editor, OpenTabs, Preview, ReadingFind, Splitter, TocPanel } from "@/components/editor";
 import { ContextMenu, Sidebar, type ContextMenuItem } from "@/components/files";
-import { AboutOverlay, CommandPalette, DropOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays";
+import { AboutOverlay, CommandPalette, DropOverlay, FilePreviewOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays";
 import { TooltipRoot } from "@/components/primitives";
 import {
   useContextMenu,
@@ -155,15 +155,55 @@ export function App() {
     });
   }, []);
 
-  const handleOpenFileRequest = useCallback(async (request: OpenFileRequest) => {
-    if (typeof request === "string") {
-      if (request.length > 0) await loadFile(request);
-      return;
-    }
-    if (request?.path) {
-      await loadFile(request.path, { waitMarker: request.waitMarker });
-    }
-  }, [loadFile]);
+  const [previewPath, setPreviewPath] = useState(null);
+  const openPreview = useCallback((path: string) => setPreviewPath(path), []);
+  const closePreview = useCallback(() => setPreviewPath(null), []);
+
+  // .md/.csv open in the editor; every other file opens the quick-preview overlay.
+  const handleSelectFile = useCallback(
+    (path: string) => {
+      if (isSupportedTextPath(path)) {
+        void loadFile(path);
+      } else {
+        openPreview(path);
+      }
+    },
+    [loadFile, openPreview],
+  );
+
+  // Space previews the focused sidebar file (Finder Quick Look style).
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key !== " " || e.metaKey || e.ctrlKey || e.altKey) return;
+      const row = (document.activeElement as HTMLElement | null)?.closest(
+        "[data-mdv-tree-path]",
+      ) as HTMLElement | null;
+      const p = row?.dataset.mdvTreePath;
+      // let Space activate the row (open in editor) for editable files; preview otherwise.
+      if (!p || isSupportedTextPath(p)) return;
+      e.preventDefault();
+      openPreview(p);
+    };
+    document.addEventListener("keydown", onKey);
+    return () => document.removeEventListener("keydown", onKey);
+  }, [openPreview]);
+
+  const handleOpenFileRequest = useCallback(
+    async (request: OpenFileRequest) => {
+      const reqPath = typeof request === "string" ? request : request?.path;
+      if (!reqPath) return;
+      if (isSupportedTextPath(reqPath)) {
+        const waitMarker = typeof request === "string" ? null : request.waitMarker ?? null;
+        await loadFile(reqPath, waitMarker ? { waitMarker } : {});
+      } else {
+        // preview isn't an editing session — release any --wait client at once.
+        openPreview(reqPath);
+        const waitMarker = typeof request === "string" ? null : request.waitMarker ?? null;
+        if (waitMarker) completeWaitSessions([waitMarker]);
+      }
+    },
+    [loadFile, openPreview, completeWaitSessions],
+  );
 
   useEffect(() => {
     waitMarkersRef.current = tabs.flatMap((tab) => tab.waitMarkers);
@@ -657,27 +697,33 @@ export function App() {
   const contextItems = useMemo(() => {
     if (!contextMenu) return [];
     const { path, isDir } = contextMenu;
-    const items: ContextMenuItem[] = [
-      {
-        label: t("menu.rename"),
-        onSelect: () => setEditingPath(path),
-      },
-      "divider",
-      {
-        label: t("menu.copyPath"),
-        onSelect: () => {
-          void navigator.clipboard.writeText(path);
-          showSaveAsToast(t("menu.pathCopied"));
-        },
+    const items: ContextMenuItem[] = [];
+    if (!isDir && !isSupportedTextPath(path)) {
+      items.push({
+        label: t("menu.quickPreview"),
+        onSelect: () => openPreview(path),
+      });
+      items.push("divider");
+    }
+    items.push({
+      label: t("menu.rename"),
+      onSelect: () => setEditingPath(path),
+    });
+    items.push("divider");
+    items.push({
+      label: t("menu.copyPath"),
+      onSelect: () => {
+        void navigator.clipboard.writeText(path);
+        showSaveAsToast(t("menu.pathCopied"));
       },
-      {
-        label: t("menu.copyRelativePath"),
-        onSelect: () => {
-          void navigator.clipboard.writeText(relativePath(path, rootPath));
-          showSaveAsToast(t("menu.pathCopied"));
-        },
+    });
+    items.push({
+      label: t("menu.copyRelativePath"),
+      onSelect: () => {
+        void navigator.clipboard.writeText(relativePath(path, rootPath));
+        showSaveAsToast(t("menu.pathCopied"));
       },
-    ];
+    });
     items.push("divider");
     items.push({
       label: t("menu.revealExplorer"),
@@ -729,7 +775,7 @@ export function App() {
       },
     });
     return items;
-  }, [contextMenu, activePath, setActivePath, bumpTree, t]);
+  }, [contextMenu, activePath, setActivePath, bumpTree, openPreview, t]);
 
   // OS "Open With → marka.md" and CLI launches — Rust emits marka:open-file.
   useEffect(() => {
@@ -1097,7 +1143,7 @@ export function App() {
               onWidthChange={setSidebarWidth}
               onAddFolder={handleOpenFolder}
               onCloseFolder={handleCloseFolder}
-              onSelectFile={(path) => void loadFile(path)}
+              onSelectFile={handleSelectFile}
               onMove={handleMove}
               onContextMenu={handleContextMenu}
               stagedPaths={stagedPaths}
@@ -1280,6 +1326,15 @@ export function App() {
       />
 
       
+
+       {
+          closePreview();
+          void loadPlainTextFile(p);
+        }}
+      />
       
 
       
         
diff --git a/src/components/overlays/file-preview-overlay.tsx b/src/components/overlays/file-preview-overlay.tsx
new file mode 100644
index 0000000..305600c
--- /dev/null
+++ b/src/components/overlays/file-preview-overlay.tsx
@@ -0,0 +1,415 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { readFile, readTextFile, stat } from "@tauri-apps/plugin-fs";
+import { openPath } from "@tauri-apps/plugin-opener";
+import { useI18n } from "@/lib";
+import { basename, isSupportedTextPath, previewKindForPath, previewMimeForPath, validatePlainTextFile } from "@/lib";
+
+const CLOSE_ICON_SVG = ``;
+const EYE_ICON_SVG = ``;
+const ZOOM_IN_ICON_SVG = ``;
+const ZOOM_OUT_ICON_SVG = ``;
+const FIT_ICON_SVG = ``;
+const ACTUAL_ICON_SVG = ``;
+
+const MIN_SCALE = 0.05;
+const MAX_SCALE = 8;
+
+function clampScale(s: number): number {
+  return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+  const chunks: string[] = [];
+  const CHUNK = 8192;
+  for (let i = 0; i < bytes.byteLength; i += CHUNK) {
+    chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
+  }
+  return btoa(chunks.join(""));
+}
+
+function formatSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+type LoadStatus = "loading" | "ready" | "error";
+
+type Props = {
+  path: string | null;
+  onClose: () => void;
+  onOpenAsText: (path: string) => void;
+};
+
+export function FilePreviewOverlay({ path, onClose, onOpenAsText }: Props) {
+  const { t } = useI18n();
+  const kind = useMemo(
+    () => (path ? previewKindForPath(path) : "unsupported"),
+    [path],
+  );
+
+  const [status, setStatus] = useState("loading");
+  const [url, setUrl] = useState(null);
+  const [text, setText] = useState("");
+  const [size, setSize] = useState(null);
+  const [errMsg, setErrMsg] = useState("");
+
+  const urlRef = useRef(null);
+
+  const revokeUrl = useCallback(() => {
+    if (urlRef.current && urlRef.current.startsWith("blob:")) {
+      URL.revokeObjectURL(urlRef.current);
+    }
+    urlRef.current = null;
+  }, []);
+
+  // Esc to close.
+  useEffect(() => {
+    if (!path) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        e.preventDefault();
+        onClose();
+      }
+    };
+    document.addEventListener("keydown", onKey);
+    return () => document.removeEventListener("keydown", onKey);
+  }, [path, onClose]);
+
+  // Load the file payload whenever the target changes.
+  useEffect(() => {
+    if (!path) return;
+    let cancelled = false;
+    revokeUrl();
+    setUrl(null);
+    setText("");
+    setSize(null);
+    setErrMsg("");
+    setStatus("loading");
+
+    void (async () => {
+      try {
+        if (kind === "office" || kind === "unsupported") {
+          let s: number | null = null;
+          try {
+            s = (await stat(path)).size;
+          } catch {
+            s = null;
+          }
+          if (cancelled) return;
+          setSize(s);
+          setStatus("ready");
+          return;
+        }
+
+        if (kind === "text") {
+          const check = await validatePlainTextFile(path);
+          if (cancelled) return;
+          if (!check.ok) {
+            setErrMsg(check.reason);
+            setStatus("error");
+            return;
+          }
+          const content = await readTextFile(path);
+          if (cancelled) return;
+          setText(content);
+          setStatus("ready");
+          return;
+        }
+
+        // image / video / audio / pdf — read bytes once.
+        const bytes = await readFile(path);
+        if (cancelled) return;
+        if (kind === "pdf") {
+          const dataUrl = `data:application/pdf;base64,${bytesToBase64(bytes)}`;
+          urlRef.current = dataUrl;
+          setUrl(dataUrl);
+        } else {
+          const blobUrl = URL.createObjectURL(
+            new Blob([bytes], { type: previewMimeForPath(path) }),
+          );
+          urlRef.current = blobUrl;
+          setUrl(blobUrl);
+        }
+        setStatus("ready");
+      } catch (err) {
+        if (cancelled) return;
+        console.error("marka.md: preview load failed", err);
+        setErrMsg(err instanceof Error ? err.message : String(err));
+        setStatus("error");
+      }
+    })();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [path, kind, revokeUrl]);
+
+  // Revoke any blob URL on unmount.
+  useEffect(() => revokeUrl, [revokeUrl]);
+
+  if (!path) return null;
+
+  const title = basename(path);
+  const showOpenAsText = kind === "text" && isSupportedTextPath(path) === false;
+
+  return (
+    
+
+
+ + + {t("preview.badge")} + + {t("preview.escHint")} +
+ {title} +
+ {showOpenAsText ? ( + + ) : null} + + +
+
+ +
+ {status === "loading" ? ( +
{t("preview.loading")}
+ ) : status === "error" ? ( +
+ {errMsg || t("preview.loadFailed")} +
+ ) : kind === "image" && url ? ( + + ) : kind === "video" && url ? ( +
+
+ ) : kind === "audio" && url ? ( +
+ {title} +
+ ) : kind === "pdf" && url ? ( +