diff --git a/package.json b/package.json index 1d935cd..9fd2d77 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@codemirror/commands": "^6.10.1", + "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.12", "@tauri-apps/api": "^2.0.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 26283a6..99d5638 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1574,9 +1574,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png 0.17.16", @@ -2244,38 +2244,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.10.0", - "objc2", "objc2-foundation", ] @@ -2303,41 +2274,6 @@ dependencies = [ "objc2-io-surface", ] -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2361,7 +2297,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", "block2", - "libc", "objc2", "objc2-core-foundation", ] @@ -2377,16 +2312,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2399,17 +2324,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2434,8 +2348,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -3074,9 +2986,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", @@ -3093,7 +3005,6 @@ dependencies = [ "pin-project-lite", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-util", @@ -4010,9 +3921,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.5" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", @@ -4061,9 +3972,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.3" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", @@ -4083,9 +3994,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -4110,9 +4021,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4236,9 +4147,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", @@ -4261,9 +4172,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.3" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", @@ -4271,7 +4182,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -4288,9 +4198,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -5001,9 +4911,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5094,9 +5004,9 @@ dependencies = [ [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -5118,9 +5028,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -5739,9 +5649,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" dependencies = [ "base64 0.22.1", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dab5d4a..bc1430e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,12 +5,12 @@ description = "TextDB" edition = "2021" [build-dependencies] -tauri-build = { version = "2", features = [] } +tauri-build = { version = "2.5", features = [] } [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri = { version = "2", features = [] } +tauri = { version = "2.10", features = [] } tauri-plugin-clipboard-manager = { version = "2" } tauri-plugin-dialog = { version = "2" } tauri-plugin-fs = { version = "2" } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7875f85..de9a088 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -63,7 +63,8 @@ }, { "ext": [ - "md" + "md", + "markdown" ], "name": "Markdown Document", "description": "Markdown document", @@ -71,8 +72,7 @@ "role": "Editor", "rank": "Default", "contentTypes": [ - "net.daringfireball.markdown", - "public.plain-text" + "net.daringfireball.markdown" ] } ] diff --git a/src/App.tsx b/src/App.tsx index 7fb327f..803e1f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,13 @@ import { appDataDir } from "@tauri-apps/api/path"; import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { Compartment, EditorState, Transaction } from "@codemirror/state"; +import { + closeSearchPanel, + openSearchPanel, + search, + searchKeymap, + searchPanelOpen +} from "@codemirror/search"; import { EditorView, keymap, @@ -88,6 +95,20 @@ type HistoryEntry = { baseVersionId?: string | null; }; +type ConversionJob = { + sourceTextId: string; + sourceTitle: string; + sourceBody: string; + controller: AbortController; +}; + +type DocumentStats = { + characters: number; + words: number; + sentences: number; + estimatedTokens: number; +}; + type SidebarEntry = | { kind: "folder"; item: Folder } | { kind: "text"; item: Text }; @@ -101,6 +122,59 @@ Only add Markdown structure (such as headings, lists, code blocks, tables, quote Keep the content itself unaltered and do not translate, summarize or rephrase. Only use your Markdown-formatting skills. Text:`; +const graphemeSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; +const wordSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "word" }) + : null; +const sentenceSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "sentence" }) + : null; + +function getDocumentStats(text: string): DocumentStats { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const characters = graphemeSegmenter + ? Array.from(graphemeSegmenter.segment(normalized)).length + : Array.from(normalized).length; + const words = wordSegmenter + ? Array.from(wordSegmenter.segment(normalized)).filter((segment) => segment.isWordLike).length + : (normalized.match(/\b[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length; + const sentences = sentenceSegmenter + ? Array.from(sentenceSegmenter.segment(normalized)).filter( + (segment) => segment.segment.trim().length > 0 + ).length + : (normalized.match(/[^.!?]+(?:[.!?]+|$)/g) ?? []) + .map((segment) => segment.trim()) + .filter(Boolean).length; + + return { + characters, + words, + sentences, + estimatedTokens: Math.round((words * 4) / 3) + }; +} + +function getImportedTitle(filePath: string) { + const filename = filePath.split(/[\\/]/).pop()?.trim() || DEFAULT_TITLE; + const lastDot = filename.lastIndexOf("."); + if (lastDot <= 0) return filename || DEFAULT_TITLE; + const stripped = filename.slice(0, lastDot).trim(); + return stripped || filename || DEFAULT_TITLE; +} + +function getFileLabel(filePath: string) { + return filePath.split(/[\\/]/).pop()?.trim() || filePath; +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + export default function App() { const [texts, setTexts] = useState([]); const [selectedTextId, setSelectedTextId] = useState(null); @@ -176,24 +250,31 @@ export default function App() { const [ollamaModels, setOllamaModels] = useState([]); const [ollamaLoading, setOllamaLoading] = useState(false); const [ollamaError, setOllamaError] = useState(null); - const [isConverting, setIsConverting] = useState(false); + const [conversionJob, setConversionJob] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem("textdb.sidebarCollapsed") === "true"; }); const [editorReady, setEditorReady] = useState(false); const bodyRef = useRef(body); + const selectedTextIdRef = useRef(selectedTextId); + const historyOpenRef = useRef(historyOpen); + const viewingVersionRef = useRef(viewingVersion); const editorViewRef = useRef(null); const editorValueRef = useRef(""); const lineNumbersCompartmentRef = useRef(new Compartment()); const editableCompartmentRef = useRef(new Compartment()); const historySnapshotRef = useRef(null); const recentOpenRef = useRef(new Map()); + const searchRestoreSplitRef = useRef(null); const ignoreTextBlurRef = useRef(false); const ignoreFolderBlurRef = useRef(false); bodyRef.current = body; + selectedTextIdRef.current = selectedTextId; + historyOpenRef.current = historyOpen; + viewingVersionRef.current = viewingVersion; useEffect(() => { @@ -258,10 +339,16 @@ export default function App() { extensions: [ EditorView.lineWrapping, history(), - keymap.of([...defaultKeymap, ...historyKeymap]), + search(), + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), lineNumbersCompartmentRef.current.of([]), editableCompartmentRef.current.of(EditorView.editable.of(true)), EditorView.updateListener.of((update) => { + if (!searchPanelOpen(update.state) && searchRestoreSplitRef.current !== null) { + const nextSplitView = searchRestoreSplitRef.current; + searchRestoreSplitRef.current = null; + setSplitView(nextSplitView); + } if (!update.docChanged) return; const value = update.state.doc.toString(); editorValueRef.current = value; @@ -279,10 +366,12 @@ export default function App() { }, []); const isViewingHistory = viewingVersion !== null; + const isConverting = conversionJob !== null; const isDirty = !isViewingHistory && body !== lastPersistedBody; const hasText = body.trim().length > 0; const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView); const hasSearch = search.trim().length > 0; + const documentStats = useMemo(() => getDocumentStats(body), [body]); const markdownHtml = useMemo( () => (markdownPreview ? markdownToHTML(body) : ""), [body, markdownPreview] @@ -435,6 +524,31 @@ export default function App() { }); }, [markdownPreview]); + const openDocumentSearch = useCallback(() => { + if (!selectedTextIdRef.current) return false; + + const revealEditor = markdownPreview && !splitView; + if (revealEditor) { + searchRestoreSplitRef.current = false; + setSplitView(true); + } else { + searchRestoreSplitRef.current = null; + } + + window.requestAnimationFrame(() => { + const view = editorViewRef.current; + if (!view) return; + openSearchPanel(view); + view.focus(); + }); + + return true; + }, [markdownPreview, splitView]); + + const handleCancelConversion = useCallback(() => { + conversionJob?.controller.abort(); + }, [conversionJob]); + const statusKey = useMemo(() => { if (isViewingHistory) return "history"; if (isDirty) return "unsaved"; @@ -456,6 +570,13 @@ export default function App() { return "Saved"; } }, [statusKey]); + const conversionLabel = useMemo(() => { + if (!conversionJob) return null; + if (conversionJob.sourceTextId === selectedTextId) { + return "Converting Markdown"; + } + return `Converting ${conversionJob.sourceTitle}`; + }, [conversionJob, selectedTextId]); const historyIconSrc = theme === "light" ? historyIconBright : historyIcon; const folderIconSrc = theme === "light" ? folderIconBright : folderIcon; @@ -552,11 +673,12 @@ export default function App() { }); }, []); - const refreshVersions = useCallback(async () => { - if (!selectedTextId || !historyOpen) return; + const refreshVersions = useCallback(async (targetTextId?: string | null) => { + const textId = targetTextId ?? selectedTextIdRef.current; + if (!textId || !historyOpenRef.current) return; const [manualRows, draft] = await Promise.all([ - listVersions(selectedTextId), - getDraft(selectedTextId) + listVersions(textId), + getDraft(textId) ]); const manualItems: HistoryEntry[] = manualRows.map((row) => ({ id: row.id, @@ -567,7 +689,7 @@ export default function App() { const draftItem: HistoryEntry[] = draft ? [ { - id: `draft:${selectedTextId}`, + id: `draft:${textId}`, created_at: draft.updated_at, kind: "draft", body: draft.body, @@ -579,7 +701,7 @@ export default function App() { (a, b) => b.created_at - a.created_at ); setHistoryItems(combined); - }, [historyOpen, selectedTextId]); + }, []); const handleConvertToMarkdown = useCallback(async () => { if (!selectedTextId || !hasText || isViewingHistory || isConverting) return; @@ -592,13 +714,23 @@ export default function App() { }); return; } + const controller = new AbortController(); const prompt = (ollamaPrompt || DEFAULT_OLLAMA_PROMPT).trim(); - const fullPrompt = `${prompt}\n${body}`; - setIsConverting(true); + const sourceTextId = selectedTextId; + const sourceBody = body; + const sourceTitle = title.trim() || DEFAULT_TITLE; + const fullPrompt = `${prompt}\n${sourceBody}`; + setConversionJob({ + sourceTextId, + sourceTitle, + sourceBody, + controller + }); try { const response = await fetch(`${normalizedOllamaUrl}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, + signal: controller.signal, body: JSON.stringify({ model: ollamaModel, prompt: fullPrompt, @@ -613,26 +745,57 @@ export default function App() { if (!resultText) { throw new Error("Ollama returned an empty response."); } - const normalizedTitle = title.trim() || DEFAULT_TITLE; - const result = await saveManualVersion( - selectedTextId, - normalizedTitle, - resultText - ); - setBody(resultText); - setLastPersistedBody(resultText); - setLastPersistedTitle(normalizedTitle); - setHasDraft(false); - setRestoredDraft(false); - setLatestManualVersionId(result.versionId); - setDraftBaseVersionId(result.versionId); - setSelectedHistoryId(result.versionId); - setViewingVersion(null); - historySnapshotRef.current = null; - setMarkdownPreview(true); + if (controller.signal.aborted) { + return; + } + const currentText = await getText(sourceTextId); + const normalizedTitle = currentText?.title?.trim() || sourceTitle; + if (controller.signal.aborted) { + return; + } + const result = await saveManualVersion(sourceTextId, normalizedTitle, resultText); + const hasLiveEditsOnSource = + selectedTextIdRef.current === sourceTextId && + viewingVersionRef.current === null && + bodyRef.current !== sourceBody; + + const canApplyToVisibleEditor = + selectedTextIdRef.current === sourceTextId && + viewingVersionRef.current === null && + !hasLiveEditsOnSource; + + if (hasLiveEditsOnSource) { + const currentBody = bodyRef.current; + await upsertDraft(sourceTextId, currentBody, result.versionId); + setHasDraft(true); + setLastPersistedBody(currentBody); + setLatestManualVersionId(result.versionId); + setDraftBaseVersionId(result.versionId); + setSelectedHistoryId(`draft:${sourceTextId}`); + } + + if (canApplyToVisibleEditor) { + setBody(resultText); + setLastPersistedBody(resultText); + setLastPersistedTitle(normalizedTitle); + setHasDraft(false); + setRestoredDraft(false); + setLatestManualVersionId(result.versionId); + setDraftBaseVersionId(result.versionId); + setSelectedHistoryId(result.versionId); + setViewingVersion(null); + historySnapshotRef.current = null; + setMarkdownPreview(true); + } + await refreshTexts(); - await refreshVersions(); + if (selectedTextIdRef.current === sourceTextId && historyOpenRef.current) { + await refreshVersions(sourceTextId); + } } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } console.error("Failed to convert with Ollama", error); setConfirmState({ title: "Ollama error", @@ -641,7 +804,9 @@ export default function App() { onConfirm: () => {} }); } finally { - setIsConverting(false); + setConversionJob((current) => + current?.controller === controller ? null : current + ); } }, [ body, @@ -687,10 +852,15 @@ export default function App() { refreshVersions().catch((error) => { console.error("Failed to load versions", error); }); - }, [historyOpen, refreshVersions]); + }, [historyOpen, refreshVersions, selectedTextId]); useEffect(() => { if (!selectedTextId) { + const view = editorViewRef.current; + if (view) { + closeSearchPanel(view); + } + searchRestoreSplitRef.current = null; setTitle(""); setLastPersistedTitle(""); setBody(""); @@ -701,7 +871,6 @@ export default function App() { setDraftBaseVersionId(null); setViewingVersion(null); setSelectedHistoryId(null); - setMarkdownPreview(false); historySnapshotRef.current = null; return; } @@ -738,7 +907,6 @@ export default function App() { setSelectedHistoryId( draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null ); - setMarkdownPreview(false); historySnapshotRef.current = null; }; @@ -1112,14 +1280,26 @@ export default function App() { const createTextFromFile = useCallback( async (filePath: string) => { try { - const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE; - const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE; + const title = getImportedTitle(filePath); const contents = await readTextFile(filePath); + if (contents.includes("\u0000")) { + throw new Error("This file appears to be binary and cannot be opened as text."); + } const { textId } = await createText(title, contents, null); await refreshTexts(); setSelectedTextId(textId); } catch (error) { console.error("Failed to open text file", error); + const fileLabel = getFileLabel(filePath); + setConfirmState({ + title: "Open file error", + message: `Unable to open "${fileLabel}" as text. ${getErrorMessage( + error, + "The file could not be decoded as text." + )}`, + actionLabel: "OK", + onConfirm: () => {} + }); } }, [refreshTexts] @@ -1128,12 +1308,8 @@ export default function App() { const handleFilePaths = useCallback( async (paths: string[]) => { const now = Date.now(); - const txtPaths = paths.filter((path) => { - const lower = path.toLowerCase(); - return lower.endsWith(".txt") || lower.endsWith(".md"); - }); const recent = recentOpenRef.current; - for (const path of txtPaths) { + for (const path of paths) { const key = path.toLowerCase(); const last = recent.get(key); if (last && now - last < 1000) continue; @@ -1193,7 +1369,6 @@ export default function App() { const path = await open({ multiple: false, directory: false, - filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }], defaultPath: baseDir }); if (!path || Array.isArray(path)) return; @@ -1427,6 +1602,16 @@ export default function App() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + const isFind = + (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f"; + if (isFind && !settingsOpen && !confirmState) { + const opened = openDocumentSearch(); + if (opened) { + event.preventDefault(); + return; + } + } + const isSave = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s"; if (isSave) { @@ -1442,7 +1627,7 @@ export default function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [confirmState, editingFolderId, editingTextId, handleSaveVersion, selectedTextId, settingsOpen]); + }, [confirmState, handleSaveVersion, openDocumentSearch, settingsOpen]); const renderTextItem = (text: Text) => (
) : null} - {hasText ? ( + {hasText || isConverting ? ( <> + {hasText ? ( + + ) : null} - - - {markdownPreview ? ( - <> - - + {hasText ? ( + + ) : null} + {hasText && markdownPreview ? ( + ) : null} ) : null} @@ -1825,6 +2012,15 @@ export default function App() { {statusLabel}
+ {conversionLabel ? ( +
{conversionLabel}
+ ) : null} +
+ {documentStats.characters} chars + {documentStats.words} words + {documentStats.sentences} sentences + {documentStats.estimatedTokens} est. tokens +