import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { open, save } from "@tauri-apps/plugin-dialog"; import { open as openExternal } from "@tauri-apps/plugin-shell"; import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager"; import { copyFile, exists, readTextFile, remove, writeTextFile } from "@tauri-apps/plugin-fs"; import { Menu } from "@tauri-apps/api/menu"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { appDataDir, join } 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, lineNumbers, highlightActiveLineGutter } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import folderIcon from "../src-tauri/icons/folder.png"; import folderIconBright from "../src-tauri/icons/folder_b.png"; import historyIcon from "../src-tauri/icons/history.png"; import historyIconBright from "../src-tauri/icons/history_b.png"; import newFolderIcon from "../src-tauri/icons/new_folder.png"; import newFolderIconBright from "../src-tauri/icons/new_folder_b.png"; import newTextIcon from "../src-tauri/icons/new_text.png"; import newTextIconBright from "../src-tauri/icons/new_text_b.png"; import openFileIcon from "../src-tauri/icons/open_file.png"; import openFileIconBright from "../src-tauri/icons/open_file_b.png"; import settingsIcon from "../src-tauri/icons/settings.png"; import settingsIconBright from "../src-tauri/icons/settings_b.png"; import sidebarCollapseIcon from "../src-tauri/icons/sb_collapse.png"; import sidebarCollapseIconBright from "../src-tauri/icons/sb_collapse_b.png"; import sidebarExpandIcon from "../src-tauri/icons/sb_expand.png"; import sidebarExpandIconBright from "../src-tauri/icons/sb_expand_b.png"; import { markdownToHTML } from "./markdown/markdown"; import "./markdown/markdown-render.css"; import { createFolder, createText, deleteFolder, deleteText, deleteTextVersion, discardDraft, exportDatabaseSnapshot, getDraft, getLatestManualVersion, getText, listFolders, listTexts, listVersions, moveFolder, moveTextToFolder, saveManualVersion, searchTexts, updateFolderName, updateTextTitle, upsertDraft, type Folder, type Text, } from "./lib/db"; const formatDate = (timestamp: number) => { if (!timestamp) return ""; return new Date(timestamp).toLocaleString(); }; const buildDatabaseExportFilename = () => { const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-"); return `textdb-backup-${timestamp}.db`; }; type HistorySnapshot = { body: string; lastPersistedBody: string; lastPersistedTitle: string; hasDraft: boolean; restoredDraft: boolean; draftBaseVersionId: string | null; latestManualVersionId: string | null; }; type ConfirmState = { title: string; message: string; actionLabel?: string; onConfirm: () => Promise | void; }; type DbExportStatus = { tone: "success" | "error"; message: string; }; type HistoryEntry = { id: string; created_at: number; kind: "manual" | "draft"; body: string; baseVersionId?: string | null; }; type ConversionJob = { actionLabel: string; openPreviewOnSuccess: boolean; sourceTextId: string; sourceTitle: string; sourceBody: string; sourceDraftBaseVersionId: string | null; controller: AbortController; }; type DocumentStats = { characters: number; words: number; sentences: number; estimatedTokens: number; }; type SidebarEntry = | { kind: "folder"; item: Folder } | { kind: "text"; item: Text }; type AiPromptKey = | "markdownConversion" | "proofreadSpelling" | "summarize" | "translate" | "changeStyle" | "rewrite"; type AiPrompts = Record; type AiPromptDefinition = { key: AiPromptKey; title: string; hint?: string; }; type AiActionRequest = { promptKey: AiPromptKey; actionLabel: string; openPreviewOnSuccess?: boolean; variables?: Record; }; const DEFAULT_TITLE = "Untitled Text"; const DEFAULT_FOLDER_NAME = "New Folder"; const DEFAULT_OLLAMA_URL = "http://localhost:11434"; const DEFAULT_TRANSLATE_LANGUAGE = "English"; const DEFAULT_CHANGE_STYLE_PRESETS = ["Friendly", "Professional", "Polite", "Concise"]; const AI_PROMPTS_STORAGE_KEY = "textdb.aiPrompts"; const LEGACY_MARKDOWN_PROMPT_STORAGE_KEY = "textdb.ollamaPrompt"; const TRANSLATE_LANGUAGE_STORAGE_KEY = "textdb.translateLanguage"; const CHANGE_STYLE_PRESETS_STORAGE_KEY = "textdb.changeStylePresets"; const DEFAULT_AI_PROMPTS: AiPrompts = { markdownConversion: `Convert the document into well-structured Markdown. Do not remove, translate, summarize, or invent content. Add Markdown formatting only where it improves structure and readability. Return only the final Markdown document.`, proofreadSpelling: `Proofread the document. Correct spelling mistakes, obvious typos, and minor punctuation issues only. Keep the wording, meaning, structure, and tone otherwise unchanged. Return only the corrected document.`, summarize: `Summarize the document into a concise version. Keep the key facts, decisions, and important details. Use clear structure and short paragraphs or bullets where helpful. Return only the final summary.`, translate: `Translate the document into {{language}}. Preserve the meaning, intent, and structure as naturally as possible. Keep Markdown formatting where it already exists or where it clearly improves readability. Return only the translated document.`, changeStyle: `Rewrite the document in a {{style}} style. Preserve the meaning, facts, and overall structure. Improve wording and tone to match the requested style without adding commentary. Return only the rewritten document.`, rewrite: `Rewrite the document for clarity and flow. Preserve the meaning and important details. Improve readability, structure, and phrasing without adding commentary. Return only the rewritten document.` }; const AI_PROMPT_DEFINITIONS: AiPromptDefinition[] = [ { key: "markdownConversion", title: "Markdown Conversion" }, { key: "proofreadSpelling", title: "Proofread - Correct Spelling" }, { key: "summarize", title: "Summarize" }, { key: "translate", title: "Translate", hint: "Uses {{language}}." }, { key: "changeStyle", title: "Change Style", hint: "Uses {{style}}." }, { key: "rewrite", title: "Rewrite" } ]; function loadAiPrompts(): AiPrompts { let stored: Partial | null = null; try { const raw = localStorage.getItem(AI_PROMPTS_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { stored = parsed as Partial; } } } catch { stored = null; } const legacyMarkdownPrompt = localStorage.getItem(LEGACY_MARKDOWN_PROMPT_STORAGE_KEY); return { ...DEFAULT_AI_PROMPTS, ...stored, markdownConversion: stored?.markdownConversion || legacyMarkdownPrompt || DEFAULT_AI_PROMPTS.markdownConversion }; } function loadChangeStylePresets() { try { const raw = localStorage.getItem(CHANGE_STYLE_PRESETS_STORAGE_KEY); if (!raw) return [...DEFAULT_CHANGE_STYLE_PRESETS]; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return [...DEFAULT_CHANGE_STYLE_PRESETS]; const normalized = parsed .map((value) => (typeof value === "string" ? value.trim() : "")) .filter(Boolean); return normalized.length > 0 ? Array.from(new Set(normalized)) : [...DEFAULT_CHANGE_STYLE_PRESETS]; } catch { return [...DEFAULT_CHANGE_STYLE_PRESETS]; } } function applyPromptVariables(template: string, variables: Record) { return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => variables[key] ?? `{{${key}}}`); } 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); const [textSearch, setTextSearch] = useState(""); const [loadingTexts, setLoadingTexts] = useState(true); const [folders, setFolders] = useState([]); const [loadingFolders, setLoadingFolders] = useState(true); const [title, setTitle] = useState(""); const [lastPersistedTitle, setLastPersistedTitle] = useState(""); const [body, setBody] = useState(""); const [lastPersistedBody, setLastPersistedBody] = useState(""); const [hasDraft, setHasDraft] = useState(false); const [restoredDraft, setRestoredDraft] = useState(false); const [latestManualVersionId, setLatestManualVersionId] = useState(null); const [draftBaseVersionId, setDraftBaseVersionId] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); const [historyItems, setHistoryItems] = useState([]); const [viewingVersion, setViewingVersion] = useState(null); const [selectedHistoryId, setSelectedHistoryId] = useState(null); const [confirmState, setConfirmState] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [dbExporting, setDbExporting] = useState(false); const [dbExportStatus, setDbExportStatus] = useState(null); const [markdownPreview, setMarkdownPreview] = useState(false); const [editingFolderId, setEditingFolderId] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [editingTextId, setEditingTextId] = useState(null); const [editingTextTitle, setEditingTextTitle] = useState(""); const [expandedFolders, setExpandedFolders] = useState>(() => { const stored = localStorage.getItem("textdb.expandedFolders"); if (!stored) return new Set(); try { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { return new Set(parsed.filter((value) => typeof value === "string")); } } catch { return new Set(); } return new Set(); }); const [theme, setTheme] = useState<"dark" | "light">(() => { const storedTheme = localStorage.getItem("textdb.theme"); if (storedTheme === "dark" || storedTheme === "light") { return storedTheme; } return storedTheme === "default" ? "dark" : "dark"; }); const [textSize, setTextSize] = useState(() => { const storedSize = Number(localStorage.getItem("textdb.textSize")); if (!Number.isNaN(storedSize) && storedSize >= 12 && storedSize <= 18) { return storedSize; } return 16; }); const [showLineNumbers, setShowLineNumbers] = useState(() => { const stored = localStorage.getItem("textdb.lineNumbers"); return stored === null ? true : stored === "true"; }); const [splitView, setSplitView] = useState(() => { const stored = localStorage.getItem("textdb.splitView"); return stored === null ? true : stored === "true"; }); const [ollamaUrl, setOllamaUrl] = useState(() => { return localStorage.getItem("textdb.ollamaUrl") || DEFAULT_OLLAMA_URL; }); const [ollamaModel, setOllamaModel] = useState(() => { return localStorage.getItem("textdb.ollamaModel") || ""; }); const [aiPrompts, setAiPrompts] = useState(() => loadAiPrompts()); const [expandedPromptKey, setExpandedPromptKey] = useState( "markdownConversion" ); const [translateLanguage, setTranslateLanguage] = useState(() => { return localStorage.getItem(TRANSLATE_LANGUAGE_STORAGE_KEY) || DEFAULT_TRANSLATE_LANGUAGE; }); const [changeStylePresets, setChangeStylePresets] = useState(() => loadChangeStylePresets()); const [newStylePreset, setNewStylePreset] = useState(""); const [ollamaModels, setOllamaModels] = useState([]); const [ollamaLoading, setOllamaLoading] = useState(false); const [ollamaError, setOllamaError] = useState(null); 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 themeCompartmentRef = useRef(new Compartment()); const historySnapshotRef = useRef(null); const recentOpenRef = useRef(new Map()); const searchRestoreSplitRef = useRef(null); const pendingEditorFocusRef = useRef(false); const ignoreTextBlurRef = useRef(false); const ignoreFolderBlurRef = useRef(false); bodyRef.current = body; selectedTextIdRef.current = selectedTextId; historyOpenRef.current = historyOpen; viewingVersionRef.current = viewingVersion; useEffect(() => { document.body.dataset.theme = theme === "dark" ? "default" : "light"; localStorage.setItem("textdb.theme", theme); }, [theme]); useEffect(() => { document.documentElement.style.setProperty("--base-font-size", `${textSize}px`); localStorage.setItem("textdb.textSize", String(textSize)); }, [textSize]); useEffect(() => { localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed)); }, [sidebarCollapsed]); useEffect(() => { localStorage.setItem("textdb.lineNumbers", String(showLineNumbers)); }, [showLineNumbers]); useEffect(() => { localStorage.setItem("textdb.splitView", String(splitView)); }, [splitView]); useEffect(() => { localStorage.setItem("textdb.ollamaUrl", ollamaUrl); }, [ollamaUrl]); useEffect(() => { localStorage.setItem("textdb.ollamaModel", ollamaModel); }, [ollamaModel]); useEffect(() => { localStorage.setItem(AI_PROMPTS_STORAGE_KEY, JSON.stringify(aiPrompts)); }, [aiPrompts]); useEffect(() => { localStorage.setItem(TRANSLATE_LANGUAGE_STORAGE_KEY, translateLanguage); }, [translateLanguage]); useEffect(() => { localStorage.setItem( CHANGE_STYLE_PRESETS_STORAGE_KEY, JSON.stringify(changeStylePresets) ); }, [changeStylePresets]); useEffect(() => { localStorage.setItem( "textdb.expandedFolders", JSON.stringify(Array.from(expandedFolders)) ); }, [expandedFolders]); useEffect(() => { if (selectedTextId) { localStorage.setItem("textdb.selectedTextId", selectedTextId); } }, [selectedTextId]); const editorThemeExtension = useMemo( () => EditorView.theme( { ".cm-content, .cm-scroller": { caretColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f" }, "&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor": { borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f", borderLeftWidth: "1.6px" }, ".cm-dropCursor": { borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f", borderLeftWidth: "1.6px" }, ".cm-fatCursor": { background: theme === "dark" ? "rgba(245, 245, 245, 0.45)" : "rgba(31, 31, 31, 0.28)" } }, { dark: theme === "dark" } ), [theme] ); const editorHostRef = useCallback((node: HTMLDivElement | null) => { if (!node) { if (editorViewRef.current) { editorViewRef.current.destroy(); editorViewRef.current = null; setEditorReady(false); } return; } if (editorViewRef.current) return; const state = EditorState.create({ doc: bodyRef.current, extensions: [ EditorView.lineWrapping, history(), search(), keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), themeCompartmentRef.current.of(editorThemeExtension), 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; setBody(value); }) ] }); const view = new EditorView({ state, parent: node }); editorViewRef.current = view; editorValueRef.current = bodyRef.current; setEditorReady(true); }, [editorThemeExtension]); 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 = textSearch.trim().length > 0; const documentStats = useMemo(() => getDocumentStats(body), [body]); const markdownHtml = useMemo( () => (markdownPreview ? markdownToHTML(body) : ""), [body, markdownPreview] ); const normalizedOllamaUrl = useMemo(() => { const trimmed = ollamaUrl.trim(); return (trimmed || DEFAULT_OLLAMA_URL).replace(/\/+$/, ""); }, [ollamaUrl]); const folderById = useMemo(() => { const map = new Map(); for (const folder of folders) { map.set(folder.id, folder); } return map; }, [folders]); const visibleFolderIds = useMemo(() => { if (!hasSearch) return null; const visible = new Set(); for (const text of texts) { let current = text.folder_id ?? null; while (current) { if (visible.has(current)) break; visible.add(current); current = folderById.get(current)?.parent_id ?? null; } } return visible; }, [folderById, hasSearch, texts]); const entriesByParent = useMemo(() => { const map = new Map(); const addEntry = (parentId: string | null, entry: SidebarEntry) => { const list = map.get(parentId); if (list) { list.push(entry); } else { map.set(parentId, [entry]); } }; for (const folder of folders) { if (hasSearch && visibleFolderIds && !visibleFolderIds.has(folder.id)) { continue; } addEntry(folder.parent_id ?? null, { kind: "folder", item: folder }); } for (const text of texts) { addEntry(text.folder_id ?? null, { kind: "text", item: text }); } for (const [key, list] of map.entries()) { list.sort((a, b) => b.item.updated_at - a.item.updated_at); map.set(key, list); } return map; }, [folders, hasSearch, texts, visibleFolderIds]); const folderChildCount = useMemo(() => { const counts = new Map(); for (const folder of folders) { if (!folder.parent_id) continue; counts.set(folder.parent_id, (counts.get(folder.parent_id) ?? 0) + 1); } for (const text of texts) { if (!text.folder_id) continue; counts.set(text.folder_id, (counts.get(text.folder_id) ?? 0) + 1); } return counts; }, [folders, texts]); const isFolderEmpty = useCallback( (folderId: string) => (folderChildCount.get(folderId) ?? 0) === 0, [folderChildCount] ); const handleMarkdownPreviewClick = useCallback( (event: React.MouseEvent) => { const target = event.target as HTMLElement | null; const copyButton = target?.closest?.(".md-codeblock__copy") as HTMLElement | null; if (copyButton) { event.preventDefault(); const encoded = copyButton.getAttribute("data-copy-code") ?? ""; const text = decodeURIComponent(encoded); if (!text) return; writeClipboardText(text).catch(() => { if (navigator?.clipboard?.writeText) { navigator.clipboard.writeText(text); } }); return; } const link = target?.closest?.("a"); if (!link) return; const href = link.getAttribute("href"); if (!href || href.startsWith("#") || href.startsWith("/")) return; event.preventDefault(); openExternal(href); }, [] ); useEffect(() => { if (!settingsOpen) return; const controller = new AbortController(); const loadModels = async () => { setOllamaLoading(true); setOllamaError(null); try { const response = await fetch(`${normalizedOllamaUrl}/api/tags`, { signal: controller.signal }); if (!response.ok) { throw new Error(`Ollama responded with ${response.status}`); } const data = await response.json(); const models = Array.isArray(data?.models) ? data.models.map((model: { name?: string }) => model.name).filter(Boolean) : []; setOllamaModels(models); if (!ollamaModel && models.length > 0) { setOllamaModel(models[0]); } } catch (error) { if (controller.signal.aborted) return; setOllamaModels([]); setOllamaError("Unable to reach Ollama."); } finally { if (!controller.signal.aborted) { setOllamaLoading(false); } } }; loadModels(); return () => controller.abort(); }, [normalizedOllamaUrl, settingsOpen]); useEffect(() => { if (!settingsOpen) return; setDbExportStatus(null); }, [settingsOpen]); const handlePrintMarkdown = useCallback(() => { if (!markdownPreview) return; document.body.classList.add("print-markdown"); const cleanup = () => { document.body.classList.remove("print-markdown"); window.removeEventListener("afterprint", cleanup); }; window.addEventListener("afterprint", cleanup); requestAnimationFrame(() => { window.print(); }); }, [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 handleUpdateAiPrompt = useCallback((key: AiPromptKey, value: string) => { setAiPrompts((current) => ({ ...current, [key]: value })); }, []); const handleAddStylePreset = useCallback(() => { const normalized = newStylePreset.trim(); if (!normalized) return; setChangeStylePresets((current) => { if (current.some((preset) => preset.toLowerCase() === normalized.toLowerCase())) { return current; } return [...current, normalized]; }); setNewStylePreset(""); }, [newStylePreset]); const statusKey = useMemo(() => { if (isViewingHistory) return "history"; if (isDirty) return "unsaved"; if (hasDraft) return "draft"; return "saved"; }, [hasDraft, isDirty, isViewingHistory]); const canSave = !isViewingHistory && (isDirty || hasDraft); const statusLabel = useMemo(() => { switch (statusKey) { case "history": return "Viewing history"; case "unsaved": return "Unsaved"; case "draft": return "Draft autosaved"; default: return "Saved"; } }, [statusKey]); const conversionLabel = useMemo(() => { if (!conversionJob) return null; if (conversionJob.sourceTextId === selectedTextId) { return conversionJob.actionLabel; } return `${conversionJob.actionLabel}: ${conversionJob.sourceTitle}`; }, [conversionJob, selectedTextId]); const historyIconSrc = theme === "light" ? historyIconBright : historyIcon; const folderIconSrc = theme === "light" ? folderIconBright : folderIcon; const newFolderIconSrc = theme === "light" ? newFolderIconBright : newFolderIcon; const newTextIconSrc = theme === "light" ? newTextIconBright : newTextIcon; const openFileIconSrc = theme === "light" ? openFileIconBright : openFileIcon; const settingsIconSrc = theme === "light" ? settingsIconBright : settingsIcon; const sidebarCollapseIconSrc = theme === "light" ? sidebarCollapseIconBright : sidebarCollapseIcon; const sidebarExpandIconSrc = theme === "light" ? sidebarExpandIconBright : sidebarExpandIcon; useEffect(() => { const view = editorViewRef.current; if (!view) return; const extensions = showLineNumbersActive ? [lineNumbers(), highlightActiveLineGutter()] : []; view.dispatch({ effects: lineNumbersCompartmentRef.current.reconfigure(extensions) }); }, [editorReady, showLineNumbersActive]); useEffect(() => { const view = editorViewRef.current; if (!view) return; view.dispatch({ effects: editableCompartmentRef.current.reconfigure( EditorView.editable.of(!isViewingHistory && (!markdownPreview || splitView)) ) }); }, [editorReady, isViewingHistory, markdownPreview, splitView]); useEffect(() => { const view = editorViewRef.current; if (!view) return; view.dispatch({ effects: themeCompartmentRef.current.reconfigure(editorThemeExtension) }); }, [editorReady, editorThemeExtension]); useEffect(() => { const view = editorViewRef.current; if (!view) return; if (body === editorValueRef.current) return; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: body }, annotations: Transaction.addToHistory.of(false) }); editorValueRef.current = body; }, [body]); useEffect(() => { const view = editorViewRef.current; if (!view) return; view.requestMeasure(); }, [markdownPreview, splitView, textSize, sidebarCollapsed, historyOpen]); useEffect(() => { if (!pendingEditorFocusRef.current) return; if (!selectedTextId || !editorReady || isViewingHistory) return; if (markdownPreview && !splitView) return; pendingEditorFocusRef.current = false; window.requestAnimationFrame(() => { editorViewRef.current?.focus(); }); }, [editorReady, isViewingHistory, markdownPreview, selectedTextId, splitView]); const refreshTexts = useCallback(async () => { setLoadingTexts(true); try { const trimmed = textSearch.trim(); const rows = trimmed ? await searchTexts(trimmed) : await listTexts(); setTexts(rows); } finally { setLoadingTexts(false); } }, [textSearch]); const refreshFolders = useCallback(async () => { setLoadingFolders(true); try { const rows = await listFolders(); setFolders(rows); } finally { setLoadingFolders(false); } }, []); const isFolderExpanded = useCallback( (folderId: string) => { if (hasSearch) { return visibleFolderIds?.has(folderId) ?? false; } return expandedFolders.has(folderId); }, [expandedFolders, hasSearch, visibleFolderIds] ); const toggleFolderExpanded = useCallback((folderId: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(folderId)) { next.delete(folderId); } else { next.add(folderId); } return next; }); }, []); const refreshVersions = useCallback(async (targetTextId?: string | null) => { const textId = targetTextId ?? selectedTextIdRef.current; if (!textId || !historyOpenRef.current) return; const [manualRows, draft] = await Promise.all([ listVersions(textId), getDraft(textId) ]); const manualItems: HistoryEntry[] = manualRows.map((row) => ({ id: row.id, created_at: row.created_at, kind: "manual", body: row.body })); const draftItem: HistoryEntry[] = draft ? [ { id: `draft:${textId}`, created_at: draft.updated_at, kind: "draft", body: draft.body, baseVersionId: draft.base_version_id ?? null } ] : []; const combined = [...draftItem, ...manualItems].sort( (a, b) => b.created_at - a.created_at ); setHistoryItems(combined); }, []); const runAiAction = useCallback( async ({ promptKey, actionLabel, openPreviewOnSuccess = false, variables = {} }: AiActionRequest) => { if (!selectedTextId || !hasText || isViewingHistory || isConverting) return; if (!ollamaModel) { setConfirmState({ title: "Ollama", message: "Select an Ollama model first.", actionLabel: "OK", onConfirm: () => {} }); return; } const resolvedTranslateLanguage = translateLanguage.trim() || DEFAULT_TRANSLATE_LANGUAGE; if (promptKey === "translate" && !resolvedTranslateLanguage) { setConfirmState({ title: "Translate", message: "Set a target language in Settings first.", actionLabel: "OK", onConfirm: () => {} }); return; } const controller = new AbortController(); const template = (aiPrompts[promptKey] || DEFAULT_AI_PROMPTS[promptKey]).trim(); const sourceTextId = selectedTextId; const sourceBody = body; const sourceTitle = title.trim() || DEFAULT_TITLE; const sourceDraftBaseVersionId = draftBaseVersionId; const fullPrompt = `${applyPromptVariables(template, { language: resolvedTranslateLanguage, ...variables })}\n\nDocument:\n${sourceBody}`; setConversionJob({ actionLabel, openPreviewOnSuccess, sourceTextId, sourceTitle, sourceBody, sourceDraftBaseVersionId, 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, stream: false }) }); if (!response.ok) { throw new Error(`Ollama responded with ${response.status}`); } const data = await response.json(); const resultText = typeof data?.response === "string" ? data.response.trim() : ""; if (!resultText) { throw new Error("Ollama returned an empty response."); } if (controller.signal.aborted) { return; } const hasLiveEditsOnSource = selectedTextIdRef.current === sourceTextId && viewingVersionRef.current === null && bodyRef.current !== sourceBody; if (hasLiveEditsOnSource) { setConfirmState({ title: "AI edit skipped", message: `${actionLabel} finished, but the source text changed while it was running. The result was not applied.`, actionLabel: "OK", onConfirm: () => {} }); return; } await upsertDraft(sourceTextId, resultText, sourceDraftBaseVersionId); const canApplyToVisibleEditor = selectedTextIdRef.current === sourceTextId && viewingVersionRef.current === null; if (canApplyToVisibleEditor) { setBody(resultText); setLastPersistedBody(resultText); setHasDraft(true); setRestoredDraft(false); setDraftBaseVersionId(sourceDraftBaseVersionId); setSelectedHistoryId(`draft:${sourceTextId}`); setViewingVersion(null); historySnapshotRef.current = null; if (openPreviewOnSuccess) { setMarkdownPreview(true); } } await refreshTexts(); if (selectedTextIdRef.current === sourceTextId && historyOpenRef.current) { await refreshVersions(sourceTextId); } } catch (error) { if (error instanceof Error && error.name === "AbortError") { return; } console.error("Failed to run AI edit", error); setConfirmState({ title: "Ollama error", message: error instanceof Error ? error.message : `${actionLabel} failed.`, actionLabel: "OK", onConfirm: () => {} }); } finally { setConversionJob((current) => current?.controller === controller ? null : current ); } }, [ aiPrompts, body, draftBaseVersionId, hasText, isConverting, isViewingHistory, normalizedOllamaUrl, ollamaModel, refreshTexts, refreshVersions, selectedTextId, title, translateLanguage ] ); const handleConvertToMarkdown = useCallback(() => { runAiAction({ promptKey: "markdownConversion", actionLabel: "Markdown Conversion", openPreviewOnSuccess: true }).catch((error) => { console.error("Failed to convert to Markdown", error); }); }, [runAiAction]); const handleOpenAiToolsMenu = useCallback(async () => { if (!selectedTextId || !hasText || isViewingHistory || isConverting) return; const menu = await Menu.new({ items: [ { text: "Markdown Conversion", action: () => { handleConvertToMarkdown(); } }, { text: "Proofread - Correct Spelling", action: () => { runAiAction({ promptKey: "proofreadSpelling", actionLabel: "Proofread - Correct Spelling" }).catch((error) => { console.error("Failed to proofread text", error); }); } }, { text: "Summarize", action: () => { runAiAction({ promptKey: "summarize", actionLabel: "Summarize" }).catch((error) => { console.error("Failed to summarize text", error); }); } }, { text: `Translate to ${translateLanguage.trim() || DEFAULT_TRANSLATE_LANGUAGE}`, action: () => { runAiAction({ promptKey: "translate", actionLabel: `Translate to ${translateLanguage.trim() || DEFAULT_TRANSLATE_LANGUAGE}` }).catch((error) => { console.error("Failed to translate text", error); }); } }, { text: "Change Style", items: changeStylePresets.map((preset) => ({ text: preset, action: () => { runAiAction({ promptKey: "changeStyle", actionLabel: `Change Style: ${preset}`, variables: { style: preset } }).catch((error) => { console.error("Failed to change style", error); }); } })) }, { text: "Rewrite", action: () => { runAiAction({ promptKey: "rewrite", actionLabel: "Rewrite" }).catch((error) => { console.error("Failed to rewrite text", error); }); } } ] }); await menu.popup(undefined, getCurrentWindow()); }, [ changeStylePresets, handleConvertToMarkdown, hasText, isConverting, isViewingHistory, runAiAction, selectedTextId, translateLanguage ]); useEffect(() => { refreshTexts().catch((error) => { console.error("Failed to load texts", error); }); }, [refreshTexts]); useEffect(() => { refreshFolders().catch((error) => { console.error("Failed to load folders", error); }); }, [refreshFolders]); useEffect(() => { if (selectedTextId || texts.length === 0) return; const storedId = localStorage.getItem("textdb.selectedTextId"); const fallback = texts[0].id; const resolved = storedId && texts.some((text) => text.id === storedId) ? storedId : fallback; setSelectedTextId(resolved); }, [selectedTextId, texts]); useEffect(() => { if (!historyOpen) { setHistoryItems([]); return; } refreshVersions().catch((error) => { console.error("Failed to load versions", error); }); }, [historyOpen, refreshVersions, selectedTextId]); useEffect(() => { if (!selectedTextId) { const view = editorViewRef.current; if (view) { closeSearchPanel(view); } searchRestoreSplitRef.current = null; setTitle(""); setLastPersistedTitle(""); setBody(""); setLastPersistedBody(""); setHasDraft(false); setRestoredDraft(false); setLatestManualVersionId(null); setDraftBaseVersionId(null); setViewingVersion(null); setSelectedHistoryId(null); historySnapshotRef.current = null; return; } let cancelled = false; const loadText = async () => { const [text, manualVersion, draft] = await Promise.all([ getText(selectedTextId), getLatestManualVersion(selectedTextId), getDraft(selectedTextId) ]); if (cancelled) return; if (!text) { setSelectedTextId(null); localStorage.removeItem("textdb.selectedTextId"); return; } const resolvedTitle = text.title || DEFAULT_TITLE; const resolvedBody = draft?.body ?? manualVersion?.body ?? ""; const baseVersionId = draft?.base_version_id ?? manualVersion?.id ?? null; setTitle(resolvedTitle); setLastPersistedTitle(resolvedTitle); setBody(resolvedBody); setLastPersistedBody(resolvedBody); setHasDraft(Boolean(draft)); setRestoredDraft(Boolean(draft)); setLatestManualVersionId(manualVersion?.id ?? null); setDraftBaseVersionId(baseVersionId); setViewingVersion(null); setSelectedHistoryId( draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null ); historySnapshotRef.current = null; }; loadText().catch((error) => { console.error("Failed to load text", error); }); return () => { cancelled = true; }; }, [selectedTextId]); useEffect(() => { if (!selectedTextId || !isDirty || isViewingHistory) return; const currentBody = body; const handle = window.setTimeout(() => { upsertDraft(selectedTextId, currentBody, draftBaseVersionId) .then(() => { if (bodyRef.current !== currentBody) return; setHasDraft(true); setLastPersistedBody(currentBody); setSelectedHistoryId(`draft:${selectedTextId}`); if (historyOpen) { refreshVersions().catch((error) => { console.error("Failed to refresh history", error); }); } }) .catch((error) => { console.error("Failed to autosave draft", error); }); }, 600); return () => window.clearTimeout(handle); }, [ body, draftBaseVersionId, historyOpen, isDirty, isViewingHistory, refreshVersions, selectedTextId ]); const handleNewText = useCallback(async () => { const { textId } = await createText(DEFAULT_TITLE, "", null); await refreshTexts(); pendingEditorFocusRef.current = true; setMarkdownPreview(false); setSelectedTextId(textId); }, [refreshTexts]); const handleNewFolder = useCallback(async () => { const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null); await refreshFolders(); setEditingTextId(null); setEditingTextTitle(""); setEditingFolderId(folderId); setEditingFolderName(DEFAULT_FOLDER_NAME); }, [refreshFolders]); const clearFolderEditing = useCallback(() => { setEditingFolderId(null); setEditingFolderName(""); }, []); const clearTextEditing = useCallback(() => { setEditingTextId(null); setEditingTextTitle(""); }, []); const startEditingFolder = useCallback((folder: Folder) => { setEditingFolderId(folder.id); setEditingFolderName(folder.name); setEditingTextId(null); setEditingTextTitle(""); }, []); const startEditingText = useCallback((text: Text) => { setEditingTextId(text.id); setEditingTextTitle(text.title); setEditingFolderId(null); setEditingFolderName(""); }, []); const commitFolderEdit = useCallback(async () => { if (!editingFolderId) return; const folderId = editingFolderId; const nextName = editingFolderName.trim() || DEFAULT_FOLDER_NAME; const currentName = folderById.get(folderId)?.name ?? ""; clearFolderEditing(); if (nextName === currentName) return; await updateFolderName(folderId, nextName); await refreshFolders(); }, [ clearFolderEditing, editingFolderId, editingFolderName, folderById, refreshFolders ]); const commitTextEdit = useCallback(async () => { if (!editingTextId) return; const textId = editingTextId; const nextTitle = editingTextTitle.trim() || DEFAULT_TITLE; const currentTitle = texts.find((text) => text.id === textId)?.title ?? ""; clearTextEditing(); if (nextTitle === currentTitle) return; if (selectedTextId === textId) { setTitle(nextTitle); setLastPersistedTitle(nextTitle); } await updateTextTitle(textId, nextTitle); await refreshTexts(); }, [ clearTextEditing, editingTextId, editingTextTitle, refreshTexts, selectedTextId, texts ]); const buildFolderPath = useCallback( (folderId: string) => { const names: string[] = []; let current: string | null = folderId; const seen = new Set(); while (current) { if (seen.has(current)) break; seen.add(current); const folder = folderById.get(current); if (!folder) break; names.unshift(folder.name); current = folder.parent_id ?? null; } return names.join(" / "); }, [folderById] ); const folderPathList = useMemo(() => { return folders .map((folder) => ({ id: folder.id, label: buildFolderPath(folder.id) })) .sort((a, b) => a.label.localeCompare(b.label)); }, [buildFolderPath, folders]); const isDescendantFolder = useCallback( (folderId: string, potentialAncestorId: string) => { let current: string | null = folderById.get(folderId)?.parent_id ?? null; while (current) { if (current === potentialAncestorId) return true; current = folderById.get(current)?.parent_id ?? null; } return false; }, [folderById] ); const handleMoveTextToFolder = useCallback( async (textId: string, folderId: string | null) => { await moveTextToFolder(textId, folderId); await refreshTexts(); if (folderId) { setExpandedFolders((prev) => { const next = new Set(prev); next.add(folderId); return next; }); } }, [refreshTexts] ); const handleMoveFolderToFolder = useCallback( async (folderId: string, parentId: string | null) => { await moveFolder(folderId, parentId); await refreshFolders(); if (parentId) { setExpandedFolders((prev) => { const next = new Set(prev); next.add(parentId); return next; }); } }, [refreshFolders] ); const handleDeleteText = useCallback( async (promptId: string) => { await deleteText(promptId); await refreshTexts(); if (selectedTextId === promptId) { setSelectedTextId(null); localStorage.removeItem("textdb.selectedTextId"); } }, [refreshTexts, selectedTextId] ); const isQuickDeleteText = useCallback( async (text: Text) => { if ((text.title || "").trim() !== DEFAULT_TITLE) return false; if (text.id === selectedTextId && body.trim().length > 0) return false; const draft = await getDraft(text.id); if (draft) return false; const versions = await listVersions(text.id); if (versions.length === 0) return true; if (versions.length > 1) return false; return versions[0].body.trim().length === 0; }, [body, selectedTextId] ); const requestDeleteText = useCallback( async (text: Text) => { const skipConfirm = await isQuickDeleteText(text); if (skipConfirm) { await handleDeleteText(text.id); return; } setConfirmState({ title: "Delete text", message: `Delete \"${text.title}\"? This removes all versions and drafts.`, actionLabel: "Delete text", onConfirm: () => handleDeleteText(text.id) }); }, [handleDeleteText, isQuickDeleteText] ); const handleDeleteFolder = useCallback( async (folderId: string) => { await deleteFolder(folderId); await Promise.all([refreshFolders(), refreshTexts()]); setExpandedFolders((prev) => { const next = new Set(prev); next.delete(folderId); return next; }); if (editingFolderId === folderId) { clearFolderEditing(); } }, [clearFolderEditing, editingFolderId, refreshFolders, refreshTexts] ); const requestDeleteFolder = useCallback( async (folder: Folder) => { if (isFolderEmpty(folder.id)) { await handleDeleteFolder(folder.id); return; } setConfirmState({ title: "Delete folder", message: "Delete this folder? Its subfolders and texts will move one level up.", actionLabel: "Delete folder", onConfirm: () => handleDeleteFolder(folder.id) }); }, [handleDeleteFolder, isFolderEmpty] ); const handleTextContextMenu = useCallback( async (event: React.MouseEvent, text: Text) => { event.preventDefault(); const textId = text.id; const items = [ { text: "Top level", action: () => { handleMoveTextToFolder(textId, null).catch((error) => { console.error("Failed to move text", error); }); } }, ...folderPathList.map((folder) => ({ text: folder.label, action: () => { handleMoveTextToFolder(textId, folder.id).catch((error) => { console.error("Failed to move text", error); }); } })) ]; const menu = await Menu.new({ items: [ { text: "Rename", action: () => startEditingText(text) }, { text: "Delete", action: () => { requestDeleteText(text).catch((error) => { console.error("Failed to delete text", error); }); } }, { text: "Move to folder", items } ] }); await menu.popup(undefined, getCurrentWindow()); }, [folderPathList, handleMoveTextToFolder, requestDeleteText, startEditingText] ); const handleFolderContextMenu = useCallback( async (event: React.MouseEvent, folder: Folder) => { event.preventDefault(); const moveTargets = [ { text: "Top level", action: () => { handleMoveFolderToFolder(folder.id, null).catch((error) => { console.error("Failed to move folder", error); }); } }, ...folderPathList .filter( (candidate) => candidate.id !== folder.id && !isDescendantFolder(candidate.id, folder.id) ) .map((candidate) => ({ text: candidate.label, action: () => { handleMoveFolderToFolder(folder.id, candidate.id).catch((error) => { console.error("Failed to move folder", error); }); } })) ]; const menu = await Menu.new({ items: [ { text: "Rename", action: () => startEditingFolder(folder) }, { text: "Delete", action: () => { requestDeleteFolder(folder).catch((error) => { console.error("Failed to delete folder", error); }); } }, { text: "Move to folder", items: moveTargets } ] }); await menu.popup(undefined, getCurrentWindow()); }, [folderPathList, handleMoveFolderToFolder, isDescendantFolder, requestDeleteFolder, startEditingFolder] ); const createTextFromFile = useCallback( async (filePath: string) => { try { 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] ); const handleFilePaths = useCallback( async (paths: string[]) => { const now = Date.now(); const recent = recentOpenRef.current; for (const path of paths) { const key = path.toLowerCase(); const last = recent.get(key); if (last && now - last < 1000) continue; recent.set(key, now); await createTextFromFile(path); } for (const [key, timestamp] of recent.entries()) { if (now - timestamp > 2000) recent.delete(key); } }, [createTextFromFile] ); useEffect(() => { let unlisten: (() => void) | null = null; getCurrentWindow() .onDragDropEvent(async (event) => { if (event.payload.type !== "drop") return; await handleFilePaths(event.payload.paths ?? []); }) .then((cleanup) => { unlisten = cleanup; }) .catch((error) => { console.error("Failed to register drag/drop handler", error); }); return () => { if (unlisten) unlisten(); }; }, [handleFilePaths]); useEffect(() => { let unlisten: (() => void) | null = null; listen("file-opened", async (event) => { await handleFilePaths(event.payload ?? []); }) .then((cleanup) => { unlisten = cleanup; }) .catch((error) => { console.error("Failed to register file-open listener", error); }); invoke("take_pending_opens") .then((paths) => handleFilePaths(paths ?? [])) .catch((error) => { console.error("Failed to load pending file opens", error); }); return () => { if (unlisten) unlisten(); }; }, [handleFilePaths]); const handleOpenText = useCallback(async () => { const baseDir = await appDataDir(); const path = await open({ multiple: false, directory: false, defaultPath: baseDir }); if (!path || Array.isArray(path)) return; await createTextFromFile(path); }, [createTextFromFile]); const handleExportDatabase = useCallback(async () => { setDbExportStatus(null); const selectedPath = await save({ defaultPath: buildDatabaseExportFilename(), filters: [{ name: "SQLite Database", extensions: ["db", "sqlite"] }] }); if (!selectedPath) return; const destinationPath = selectedPath.endsWith(".db") || selectedPath.endsWith(".sqlite") ? selectedPath : `${selectedPath}.db`; setDbExporting(true); let snapshotPath: string | null = null; try { if (selectedTextId && isDirty && !isViewingHistory) { await upsertDraft(selectedTextId, body, draftBaseVersionId); setHasDraft(true); setLastPersistedBody(body); setSelectedHistoryId(`draft:${selectedTextId}`); if (historyOpen) { await refreshVersions(); } } const baseDir = await appDataDir(); const sourcePath = await join(baseDir, "text.db"); snapshotPath = await join(baseDir, `textdb-export-${Date.now()}.db`); if (destinationPath === sourcePath) { throw new Error("Choose a different destination than the live database file."); } if (await exists(snapshotPath)) { await remove(snapshotPath); } if (await exists(destinationPath)) { await remove(destinationPath); } await exportDatabaseSnapshot(snapshotPath); await copyFile(snapshotPath, destinationPath); setDbExportStatus({ tone: "success", message: `Database exported to ${destinationPath}` }); } catch (error) { console.error("Failed to export database", error); setDbExportStatus({ tone: "error", message: error instanceof Error ? error.message : "Failed to export database." }); } finally { if (snapshotPath && (await exists(snapshotPath).catch(() => false))) { await remove(snapshotPath).catch(() => {}); } setDbExporting(false); } }, [ body, draftBaseVersionId, historyOpen, isDirty, isViewingHistory, refreshVersions, selectedTextId ]); const handleSaveVersion = useCallback(async () => { if (!selectedTextId || !canSave) return; const normalizedTitle = title.trim() || DEFAULT_TITLE; if (normalizedTitle !== title) { setTitle(normalizedTitle); } const result = await saveManualVersion(selectedTextId, normalizedTitle, body); setLastPersistedBody(body); setLastPersistedTitle(normalizedTitle); setHasDraft(false); setRestoredDraft(false); setLatestManualVersionId(result.versionId); setDraftBaseVersionId(result.versionId); setSelectedHistoryId(result.versionId); await refreshTexts(); await refreshVersions(); }, [body, canSave, refreshTexts, refreshVersions, selectedTextId, title]); const applyTitleUpdate = useCallback((promptId: string, nextTitle: string) => { const now = Date.now(); setTexts((prev) => prev.map((text) => text.id === promptId ? { ...text, title: nextTitle, updated_at: now } : text ) ); }, []); const handleTitleChange = useCallback( (event: React.ChangeEvent) => { const nextTitle = event.target.value; setTitle(nextTitle); if (!selectedTextId || isViewingHistory) return; applyTitleUpdate(selectedTextId, nextTitle); setLastPersistedTitle(nextTitle); updateTextTitle(selectedTextId, nextTitle).catch((error) => { console.error("Failed to update title", error); }); }, [applyTitleUpdate, isViewingHistory, selectedTextId] ); const handleDiscardDraft = useCallback(async () => { if (!selectedTextId || isViewingHistory) return; await discardDraft(selectedTextId); const manualVersion = await getLatestManualVersion(selectedTextId); const resolvedBody = manualVersion?.body ?? ""; setBody(resolvedBody); setLastPersistedBody(resolvedBody); setHasDraft(false); setRestoredDraft(false); setLatestManualVersionId(manualVersion?.id ?? null); setDraftBaseVersionId(manualVersion?.id ?? null); setSelectedHistoryId(manualVersion?.id ?? null); await refreshVersions(); }, [isViewingHistory, refreshVersions, selectedTextId]); const handleExportText = useCallback(async () => { if (!hasText) return; const filename = `${title.trim() || DEFAULT_TITLE}.txt`; const path = await save({ defaultPath: filename, filters: [{ name: "Text", extensions: ["txt"] }] }); if (!path) return; const finalPath = path.endsWith(".txt") ? path : `${path}.txt`; await writeTextFile(finalPath, body); }, [body, hasText, title]); const handleExitHistory = useCallback(() => { const snapshot = historySnapshotRef.current; if (snapshot) { setBody(snapshot.body); setLastPersistedBody(snapshot.lastPersistedBody); setLastPersistedTitle(snapshot.lastPersistedTitle); setHasDraft(snapshot.hasDraft); setRestoredDraft(snapshot.restoredDraft); setDraftBaseVersionId(snapshot.draftBaseVersionId); setLatestManualVersionId(snapshot.latestManualVersionId); } setViewingVersion(null); setSelectedHistoryId(null); historySnapshotRef.current = null; }, []); const applyVersionAsCurrent = useCallback( (version: HistoryEntry) => { const snapshot = historySnapshotRef.current; if (snapshot) { setLastPersistedBody(snapshot.lastPersistedBody); setLastPersistedTitle(snapshot.lastPersistedTitle); setHasDraft(snapshot.hasDraft); setRestoredDraft(snapshot.restoredDraft); setLatestManualVersionId(snapshot.latestManualVersionId); } setDraftBaseVersionId( version.kind === "manual" ? version.id : version.baseVersionId ?? null ); setBody(version.body); setLastPersistedBody(version.body); setViewingVersion(null); setSelectedHistoryId(version.id); historySnapshotRef.current = null; }, [] ); const handleDeleteVersion = useCallback( async (version: HistoryEntry) => { if (!selectedTextId) return; const currentItems = historyItems; const index = currentItems.findIndex((item) => item.id === version.id); const olderCandidate = index >= 0 ? currentItems[index + 1] ?? null : null; const fallbackCandidate = index > 0 ? currentItems[index - 1] ?? null : null; const shouldAutoSelect = historyOpen && (!viewingVersion || viewingVersion.id === version.id); if (version.kind === "draft") { await discardDraft(selectedTextId); setHasDraft(false); setRestoredDraft(false); setDraftBaseVersionId(latestManualVersionId ?? null); if (historySnapshotRef.current) { historySnapshotRef.current.hasDraft = false; historySnapshotRef.current.restoredDraft = false; historySnapshotRef.current.draftBaseVersionId = latestManualVersionId ?? null; } } else { await deleteTextVersion(selectedTextId, version.id); } await refreshVersions(); if (version.kind === "manual" && latestManualVersionId === version.id) { const manualVersion = await getLatestManualVersion(selectedTextId); const resolvedBody = manualVersion?.body ?? ""; const nextManualId = manualVersion?.id ?? null; setLatestManualVersionId(nextManualId); setDraftBaseVersionId(nextManualId); if (historySnapshotRef.current) { historySnapshotRef.current.latestManualVersionId = nextManualId; historySnapshotRef.current.draftBaseVersionId = nextManualId; } if (!hasDraft && !isViewingHistory) { setBody(resolvedBody); setLastPersistedBody(resolvedBody); } } if (shouldAutoSelect) { if (olderCandidate) { applyVersionAsCurrent(olderCandidate); } else if (fallbackCandidate) { applyVersionAsCurrent(fallbackCandidate); } else { handleExitHistory(); } } }, [ applyVersionAsCurrent, handleExitHistory, hasDraft, isViewingHistory, historyOpen, latestManualVersionId, refreshVersions, selectedTextId, historyItems, viewingVersion ] ); const handleConfirm = useCallback(async () => { if (!confirmState) return; const action = confirmState.onConfirm; setConfirmState(null); try { await action(); } catch (error) { console.error("Delete failed", error); } }, [confirmState]); const handleTitleBlur = useCallback(async () => { if (!selectedTextId || isViewingHistory) return; const normalizedTitle = title.trim() || DEFAULT_TITLE; if (normalizedTitle !== title) { setTitle(normalizedTitle); applyTitleUpdate(selectedTextId, normalizedTitle); } if (normalizedTitle === lastPersistedTitle) return; await updateTextTitle(selectedTextId, normalizedTitle); setLastPersistedTitle(normalizedTitle); await refreshTexts(); }, [ applyTitleUpdate, isViewingHistory, lastPersistedTitle, refreshTexts, selectedTextId, title ]); const handleToggleHistory = useCallback(() => { if (historyOpen && viewingVersion) { const snapshot = historySnapshotRef.current; if (snapshot) { setBody(snapshot.body); setLastPersistedBody(snapshot.lastPersistedBody); setLastPersistedTitle(snapshot.lastPersistedTitle); setHasDraft(snapshot.hasDraft); setRestoredDraft(snapshot.restoredDraft); setDraftBaseVersionId(snapshot.draftBaseVersionId); setLatestManualVersionId(snapshot.latestManualVersionId); } setViewingVersion(null); historySnapshotRef.current = null; } setHistoryOpen((prev) => !prev); }, [historyOpen, viewingVersion]); 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) { event.preventDefault(); handleSaveVersion().catch((error) => { console.error("Failed to save version", error); }); return; } if (event.key === "Tab") return; }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [confirmState, handleSaveVersion, openDocumentSearch, settingsOpen]); const renderTextItem = (text: Text) => (
{ if (editingTextId === text.id) return; setSelectedTextId(text.id); }} onContextMenu={(event) => handleTextContextMenu(event, text)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedTextId(text.id); } }} >
{editingTextId === text.id ? ( setEditingTextTitle(event.target.value)} onClick={(event) => event.stopPropagation()} onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitTextEdit().catch((error) => { console.error("Failed to rename text", error); }); } else if (event.key === "Escape") { event.preventDefault(); ignoreTextBlurRef.current = true; clearTextEditing(); } }} onBlur={() => { if (ignoreTextBlurRef.current) { ignoreTextBlurRef.current = false; return; } commitTextEdit().catch((error) => { console.error("Failed to rename text", error); }); }} autoFocus /> ) : (
{text.title}
)}
); const renderFolder = (folder: Folder) => { if (hasSearch && !visibleFolderIds?.has(folder.id)) return null; const expanded = isFolderExpanded(folder.id); const childEntries = entriesByParent.get(folder.id) ?? []; return (
{ if (editingFolderId === folder.id) return; toggleFolderExpanded(folder.id); }} onContextMenu={(event) => handleFolderContextMenu(event, folder)} >
{editingFolderId === folder.id ? ( setEditingFolderName(event.target.value)} onClick={(event) => event.stopPropagation()} onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitFolderEdit().catch((error) => { console.error("Failed to rename folder", error); }); } else if (event.key === "Escape") { event.preventDefault(); ignoreFolderBlurRef.current = true; clearFolderEditing(); } }} onBlur={() => { if (ignoreFolderBlurRef.current) { ignoreFolderBlurRef.current = false; return; } commitFolderEdit().catch((error) => { console.error("Failed to rename folder", error); }); }} autoFocus /> ) : (
{folder.name}
)}
{expanded && childEntries.length > 0 ? (
{childEntries.map((entry) => entry.kind === "folder" ? renderFolder(entry.item) : renderTextItem(entry.item) )}
) : null}
); }; return (
{!sidebarCollapsed ? ( ) : null}
{!selectedTextId ? ( <>
Create your first text
Everything stays offline in a single SQLite database.
{sidebarCollapsed ? (
) : null} ) : (
{sidebarCollapsed ? ( ) : null} {hasText || isConverting ? ( <> {hasText ? ( ) : null} {hasText ? ( ) : null} {hasText && markdownPreview ? ( ) : null} ) : null} {hasDraft && !isViewingHistory ? ( ) : null}
{statusLabel}
{conversionLabel ? (
{conversionLabel}
) : null}
{documentStats.characters} chars {documentStats.words} words {documentStats.sentences} sentences {documentStats.estimatedTokens} est. tokens
{historyOpen ? ( ) : null}
)}
{settingsOpen ? (
setSettingsOpen(false)} />
Settings
Interface
setShowLineNumbers(event.target.checked)} />
setSplitView(event.target.checked)} />
setTextSize(Number(event.target.value))} />
{textSize}px
Ollama
setOllamaUrl(event.target.value)} placeholder={DEFAULT_OLLAMA_URL} />
{ollamaError ? (
{ollamaError}
) : null}