import React, { useCallback, useEffect, useLayoutEffect, 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 { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; import { Menu } from "@tauri-apps/api/menu"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { appDataDir } from "@tauri-apps/api/path"; import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import historyIcon from "./assets/history.png"; import historyIconBright from "./assets/history_b.png"; import { markdownToHTML } from "./markdown/markdown"; import "./markdown/markdown-render.css"; import { createFolder, createText, deleteText, deleteTextVersion, discardDraft, getDraft, getLatestManualVersion, getText, listFolders, listTexts, listVersions, moveFolder, moveTextToFolder, saveManualVersion, searchTexts, setFolderOrder, setTextOrder, updateFolderName, updateTextTitle, upsertDraft, type Folder, type Text, } from "./lib/db"; const formatDate = (timestamp: number) => { if (!timestamp) return ""; return new Date(timestamp).toLocaleString(); }; 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 HistoryEntry = { id: string; created_at: number; kind: "manual" | "draft"; body: string; baseVersionId?: string | null; }; const DEFAULT_TITLE = "Untitled Text"; export default function App() { const [texts, setTexts] = useState([]); const [selectedTextId, setSelectedTextId] = useState(null); const [search, setSearch] = 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 [markdownPreview, setMarkdownPreview] = useState(false); 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<"default" | "light">(() => { const storedTheme = localStorage.getItem("textdb.theme"); return storedTheme === "light" ? "light" : "default"; }); 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(() => { return localStorage.getItem("textdb.lineNumbers") === "true"; }); const [lineHeights, setLineHeights] = useState([]); const [measureTick, setMeasureTick] = useState(0); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem("textdb.sidebarCollapsed") === "true"; }); const bodyRef = useRef(body); const textareaRef = useRef(null); const lineNumbersRef = useRef(null); const measureRef = useRef(null); const historySnapshotRef = useRef(null); const recentOpenRef = useRef(new Map()); const dragItemRef = useRef<{ type: "text" | "folder"; id: string; parentId: string | null } | null>( null ); useEffect(() => { bodyRef.current = body; }, [body]); useEffect(() => { document.body.dataset.theme = theme; 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.expandedFolders", JSON.stringify(Array.from(expandedFolders)) ); }, [expandedFolders]); useEffect(() => { if (selectedTextId) { localStorage.setItem("textdb.selectedTextId", selectedTextId); } }, [selectedTextId]); const isViewingHistory = viewingVersion !== null; const isDirty = !isViewingHistory && body !== lastPersistedBody; const hasText = body.trim().length > 0; const showLineNumbersActive = showLineNumbers && !markdownPreview; const hasSearch = search.trim().length > 0; const sortByOrderThenUpdated = useCallback( (a: T, b: T) => { const aHasOrder = a.sort_order !== null && a.sort_order !== undefined; const bHasOrder = b.sort_order !== null && b.sort_order !== undefined; if (aHasOrder && bHasOrder) { return (a.sort_order ?? 0) - (b.sort_order ?? 0); } if (aHasOrder !== bHasOrder) { return aHasOrder ? -1 : 1; } return b.updated_at - a.updated_at; }, [] ); const folderById = useMemo(() => { const map = new Map(); for (const folder of folders) { map.set(folder.id, folder); } return map; }, [folders]); const foldersByParent = useMemo(() => { const map = new Map(); for (const folder of folders) { const key = folder.parent_id ?? null; const list = map.get(key); if (list) { list.push(folder); } else { map.set(key, [folder]); } } for (const [key, list] of map.entries()) { map.set(key, [...list].sort(sortByOrderThenUpdated)); } return map; }, [folders, sortByOrderThenUpdated]); const textsByFolder = useMemo(() => { const map = new Map(); for (const text of texts) { const key = text.folder_id ?? null; const list = map.get(key); if (list) { list.push(text); } else { map.set(key, [text]); } } for (const [key, list] of map.entries()) { map.set(key, [...list].sort(sortByOrderThenUpdated)); } return map; }, [texts, sortByOrderThenUpdated]); 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 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); }, [] ); 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 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 historyIconSrc = theme === "light" ? historyIconBright : historyIcon; const lines = useMemo(() => body.split("\n"), [body]); const lineNumbers = useMemo(() => lines.map((_, index) => index + 1), [lines]); const handleTextareaScroll = useCallback((event: React.UIEvent) => { if (!showLineNumbersActive) return; if (lineNumbersRef.current) { lineNumbersRef.current.scrollTop = event.currentTarget.scrollTop; } }, [showLineNumbersActive]); useEffect(() => { if (!showLineNumbersActive) return; const textarea = textareaRef.current; if (!textarea || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { setMeasureTick((tick) => tick + 1); }); observer.observe(textarea); return () => observer.disconnect(); }, [showLineNumbersActive]); useEffect(() => { if (!showLineNumbersActive) return; let raf = 0; const handleResize = () => { if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { setMeasureTick((tick) => tick + 1); }); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); if (raf) cancelAnimationFrame(raf); }; }, [showLineNumbersActive]); useLayoutEffect(() => { if (!showLineNumbersActive) return; const textarea = textareaRef.current; const measure = measureRef.current; if (!textarea || !measure) return; measure.style.width = `${textarea.clientWidth}px`; const heights = Array.from(measure.children).map((child) => Math.ceil((child as HTMLElement).getBoundingClientRect().height) ); setLineHeights(heights); if (lineNumbersRef.current) { lineNumbersRef.current.scrollTop = textarea.scrollTop; } }, [lines, showLineNumbersActive, textSize, measureTick, sidebarCollapsed, historyOpen]); useEffect(() => { if (showLineNumbersActive && textareaRef.current && lineNumbersRef.current) { lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop; } }, [showLineNumbersActive, body]); const refreshTexts = useCallback(async () => { setLoadingTexts(true); try { const trimmed = search.trim(); const rows = trimmed ? await searchTexts(trimmed) : await listTexts(); setTexts(rows); } finally { setLoadingTexts(false); } }, [search]); const refreshFolders = useCallback(async () => { setLoadingFolders(true); try { const rows = await listFolders(); setFolders(rows); } finally { setLoadingFolders(false); } }, []); const getNextTextSortOrder = useCallback( (folderId: string | null) => { const list = textsByFolder.get(folderId ?? null) ?? []; const hasManualOrder = list.some((text) => text.sort_order !== null); if (!hasManualOrder) return null; return list.length; }, [textsByFolder] ); const getNextFolderSortOrder = useCallback( (parentId: string | null) => { const list = foldersByParent.get(parentId ?? null) ?? []; const hasManualOrder = list.some((folder) => folder.sort_order !== null); if (!hasManualOrder) return null; return list.length; }, [foldersByParent] ); 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 () => { if (!selectedTextId || !historyOpen) return; const [manualRows, draft] = await Promise.all([ listVersions(selectedTextId), getDraft(selectedTextId) ]); 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:${selectedTextId}`, 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); }, [historyOpen, selectedTextId]); 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]); useEffect(() => { if (!selectedTextId) { setTitle(""); setLastPersistedTitle(""); setBody(""); setLastPersistedBody(""); setHasDraft(false); setRestoredDraft(false); setLatestManualVersionId(null); setDraftBaseVersionId(null); setViewingVersion(null); setSelectedHistoryId(null); setMarkdownPreview(false); 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 ); setMarkdownPreview(false); 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 sortOrder = getNextTextSortOrder(null); const { textId } = await createText(DEFAULT_TITLE, "", null, sortOrder); await refreshTexts(); setSelectedTextId(textId); }, [getNextTextSortOrder, refreshTexts]); const handleNewFolder = useCallback(async () => { const name = window.prompt("Folder name"); const trimmed = name?.trim(); if (!trimmed) return; const sortOrder = getNextFolderSortOrder(null); const { folderId } = await createFolder(trimmed, null, sortOrder); await refreshFolders(); setExpandedFolders((prev) => { const next = new Set(prev); next.add(folderId); return next; }); }, [getNextFolderSortOrder, refreshFolders]); 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 handleMoveTextToFolder = useCallback( async (textId: string, folderId: string | null) => { await moveTextToFolder(textId, folderId, null); await refreshTexts(); }, [refreshTexts] ); const handleTextContextMenu = useCallback( async (event: React.MouseEvent, textId: string) => { event.preventDefault(); 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: "Move to folder", items } ] }); await menu.popup(undefined, getCurrentWindow()); }, [folderPathList, handleMoveTextToFolder] ); const reorderIds = useCallback((ids: string[], draggedId: string, targetId: string) => { const next = ids.filter((id) => id !== draggedId); const targetIndex = next.indexOf(targetId); const insertIndex = targetIndex === -1 ? next.length : targetIndex; next.splice(insertIndex, 0, draggedId); return next; }, []); const isDescendantFolder = useCallback( (folderId: string, potentialAncestorId: string) => { let current: string | null = folderId; while (current) { if (current === potentialAncestorId) return true; current = folderById.get(current)?.parent_id ?? null; } return false; }, [folderById] ); const handleTextDrop = useCallback( async (event: React.DragEvent, targetTextId: string, targetFolderId: string | null) => { event.preventDefault(); event.stopPropagation(); const dragItem = dragItemRef.current; if (!dragItem || dragItem.type !== "text") return; const currentList = textsByFolder.get(targetFolderId ?? null) ?? []; let ids = currentList.map((text) => text.id); if (!ids.includes(dragItem.id)) { const targetIndex = ids.indexOf(targetTextId); ids.splice(targetIndex === -1 ? ids.length : targetIndex, 0, dragItem.id); } else { ids = reorderIds(ids, dragItem.id, targetTextId); } await moveTextToFolder(dragItem.id, targetFolderId, 0); await setTextOrder(ids); await refreshTexts(); dragItemRef.current = null; }, [refreshTexts, reorderIds, textsByFolder] ); const handleFolderDrop = useCallback( async (event: React.DragEvent, targetFolder: Folder) => { event.preventDefault(); event.stopPropagation(); const dragItem = dragItemRef.current; if (!dragItem) return; if (dragItem.type === "text") { const list = textsByFolder.get(targetFolder.id) ?? []; const ids = list.map((text) => text.id); if (!ids.includes(dragItem.id)) { ids.push(dragItem.id); } await moveTextToFolder(dragItem.id, targetFolder.id, 0); await setTextOrder(ids); await refreshTexts(); dragItemRef.current = null; return; } if (dragItem.type === "folder") { if (dragItem.id === targetFolder.id) return; if (isDescendantFolder(targetFolder.id, dragItem.id)) return; const sameParent = dragItem.parentId === targetFolder.parent_id; if (sameParent) { const siblings = foldersByParent.get(targetFolder.parent_id ?? null) ?? []; const ids = siblings.map((folder) => folder.id); const next = reorderIds(ids, dragItem.id, targetFolder.id); await setFolderOrder(next); await refreshFolders(); } else { const children = foldersByParent.get(targetFolder.id) ?? []; const ids = children.map((folder) => folder.id); ids.push(dragItem.id); await moveFolder(dragItem.id, targetFolder.id, 0); await setFolderOrder(ids); await refreshFolders(); } dragItemRef.current = null; } }, [foldersByParent, isDescendantFolder, refreshFolders, refreshTexts, reorderIds, textsByFolder] ); const handleRootDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); const dragItem = dragItemRef.current; if (!dragItem) return; if (dragItem.type === "text") { const list = textsByFolder.get(null) ?? []; const ids = list.map((text) => text.id); if (!ids.includes(dragItem.id)) { ids.push(dragItem.id); } await moveTextToFolder(dragItem.id, null, 0); await setTextOrder(ids); await refreshTexts(); } else if (dragItem.type === "folder") { const list = foldersByParent.get(null) ?? []; const ids = list.map((folder) => folder.id); if (!ids.includes(dragItem.id)) { ids.push(dragItem.id); } await moveFolder(dragItem.id, null, 0); await setFolderOrder(ids); await refreshFolders(); } dragItemRef.current = null; }, [foldersByParent, refreshFolders, refreshTexts, textsByFolder] ); const handleDragStartText = useCallback((event: React.DragEvent, text: Text) => { dragItemRef.current = { type: "text", id: text.id, parentId: text.folder_id ?? null }; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", text.id); }, []); const handleDragStartFolder = useCallback( (event: React.DragEvent, folder: Folder) => { dragItemRef.current = { type: "folder", id: folder.id, parentId: folder.parent_id ?? null }; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", folder.id); }, [] ); const handleDragEnd = useCallback(() => { dragItemRef.current = null; }, []); const createTextFromFile = useCallback( async (filePath: string) => { try { const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE; const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE; const contents = await readTextFile(filePath); const sortOrder = getNextTextSortOrder(null); const { textId } = await createText(title, contents, null, sortOrder); await refreshTexts(); setSelectedTextId(textId); } catch (error) { console.error("Failed to open text file", error); } }, [getNextTextSortOrder, refreshTexts] ); const handleFilePaths = useCallback( async (paths: string[]) => { const now = Date.now(); const txtPaths = paths.filter((path) => { const lower = path.toLowerCase(); return lower.endsWith(".txt") || lower.endsWith(".md"); }); const recent = recentOpenRef.current; for (const path of txtPaths) { 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, filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }], defaultPath: baseDir }); if (!path || Array.isArray(path)) return; await createTextFromFile(path); }, [createTextFromFile]); const handleDeleteText = useCallback( async (promptId: string) => { await deleteText(promptId); await refreshTexts(); if (selectedTextId === promptId) { setSelectedTextId(null); localStorage.removeItem("textdb.selectedTextId"); } }, [refreshTexts, 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) => { let updated: Text | null = null; const remaining: Text[] = []; for (const text of prev) { if (text.id === promptId) { updated = { ...text, title: nextTitle, updated_at: now }; } else { remaining.push(text); } } if (!updated) return prev; return [updated, ...remaining]; }); }, []); 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) => { 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" && !event.metaKey && !event.ctrlKey && !event.altKey && selectedTextId && !settingsOpen && !confirmState ) { event.preventDefault(); setMarkdownPreview((value) => !value); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [confirmState, handleSaveVersion, selectedTextId, settingsOpen]); return (
{!sidebarCollapsed ? ( ) : null}
{sidebarCollapsed ? (
) : null} {!selectedTextId ? (
Create your first text
Everything stays offline in a single SQLite database.
) : (
{showLineNumbersActive ? ( ) : null} {showLineNumbersActive ? (
{lineNumbers.map((line, index) => (
{line}
))}
) : null} {markdownPreview ? (
) : (