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 = { 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 }; const DEFAULT_TITLE = "Untitled Text"; const DEFAULT_FOLDER_NAME = "New Folder"; const DEFAULT_OLLAMA_URL = "http://localhost:11434"; const DEFAULT_OLLAMA_PROMPT = `Convert the following plain text into well-formatted Markdown. Do not change or omit any content. Only add Markdown structure (such as headings, lists, code blocks, tables, quotes, links, bold, italics, etc.) where appropriate, based on the meaning and structure of the original text. 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); 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 [ollamaPrompt, setOllamaPrompt] = useState(() => { return localStorage.getItem("textdb.ollamaPrompt") || DEFAULT_OLLAMA_PROMPT; }); 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("textdb.ollamaPrompt", ollamaPrompt); }, [ollamaPrompt]); 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 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 "Converting Markdown"; } return `Converting ${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 handleConvertToMarkdown = useCallback(async () => { if (!selectedTextId || !hasText || isViewingHistory || isConverting) return; if (!ollamaModel) { setConfirmState({ title: "Ollama", message: "Select an Ollama model first.", actionLabel: "OK", onConfirm: () => {} }); return; } const controller = new AbortController(); const prompt = (ollamaPrompt || DEFAULT_OLLAMA_PROMPT).trim(); 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, 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 : ""; if (!resultText) { throw new Error("Ollama returned an empty response."); } 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(); 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", message: error instanceof Error ? error.message : "Conversion failed.", actionLabel: "OK", onConfirm: () => {} }); } finally { setConversionJob((current) => current?.controller === controller ? null : current ); } }, [ body, hasText, isConverting, isViewingHistory, normalizedOllamaUrl, ollamaModel, ollamaPrompt, refreshTexts, refreshVersions, selectedTextId, title ]); 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 destinationPath = await save({ defaultPath: buildDatabaseExportFilename(), filters: [{ name: "SQLite Database", extensions: ["db", "sqlite"] }] }); if (!destinationPath) return; 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}