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 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, 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(); }; 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; }; class FenwickTree { private size: number; private tree: Float64Array; constructor(size: number) { this.size = size; this.tree = new Float64Array(size + 1); } reset(size: number) { this.size = size; this.tree = new Float64Array(size + 1); } add(index: number, delta: number) { if (!Number.isFinite(delta) || delta === 0) return; for (let i = index + 1; i <= this.size; i += i & -i) { this.tree[i] += delta; } } sum(index: number) { let result = 0; for (let i = index; i > 0; i -= i & -i) { result += this.tree[i]; } return result; } } type SidebarEntry = | { kind: "folder"; item: Folder } | { kind: "text"; item: Text }; const DEFAULT_TITLE = "Untitled Text"; const DEFAULT_FOLDER_NAME = "New Folder"; 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 [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<"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 [measureTick, setMeasureTick] = useState(0); const [defaultLineHeight, setDefaultLineHeight] = useState(0); const [lineNumbersVersion, setLineNumbersVersion] = useState(0); const [visibleRange, setVisibleRange] = useState({ start: 0, end: -1 }); 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 measureLineRef = useRef(null); const lineHeightsRef = useRef([]); const fenwickRef = useRef(new FenwickTree(0)); const scrollRafRef = useRef(0); const historySnapshotRef = useRef(null); const recentOpenRef = useRef(new Map()); const ignoreTextBlurRef = useRef(false); const ignoreFolderBlurRef = useRef(false); 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 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 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 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; const lines = useMemo(() => body.split("\n"), [body]); const lineCount = lines.length; const getLineTop = useCallback( (index: number) => { if (!defaultLineHeight) return 0; return index * defaultLineHeight + fenwickRef.current.sum(index); }, [defaultLineHeight, lineNumbersVersion] ); const getTotalHeight = useCallback(() => { if (!defaultLineHeight) return 0; return lineCount * defaultLineHeight + fenwickRef.current.sum(lineCount); }, [defaultLineHeight, lineCount, lineNumbersVersion]); const findLineAtOffset = useCallback( (offset: number) => { if (!defaultLineHeight || lineCount === 0) return 0; let low = 0; let high = lineCount; while (low < high) { const mid = Math.floor((low + high) / 2); if (getLineTop(mid + 1) <= offset) { low = mid + 1; } else { high = mid; } } return Math.min(Math.max(0, low), Math.max(0, lineCount - 1)); }, [defaultLineHeight, getLineTop, lineCount] ); const updateVisibleRange = useCallback(() => { if (!showLineNumbersActive || !defaultLineHeight) return; const textarea = textareaRef.current; if (!textarea) return; const scrollTop = textarea.scrollTop; const viewportHeight = textarea.clientHeight; const start = findLineAtOffset(scrollTop); const end = findLineAtOffset(scrollTop + viewportHeight); const buffer = 8; const nextStart = Math.max(0, start - buffer); const nextEnd = Math.min(lineCount - 1, end + buffer); setVisibleRange((prev) => prev.start === nextStart && prev.end === nextEnd ? prev : { start: nextStart, end: nextEnd } ); }, [defaultLineHeight, findLineAtOffset, lineCount, showLineNumbersActive]); const totalLineNumberHeight = useMemo( () => (showLineNumbersActive ? getTotalHeight() : 0), [getTotalHeight, showLineNumbersActive] ); const visibleLineNumbers = useMemo(() => { if (!showLineNumbersActive || visibleRange.end < visibleRange.start) return []; const items: { line: number; top: number; height: number }[] = []; for (let i = visibleRange.start; i <= visibleRange.end; i += 1) { const height = lineHeightsRef.current[i] || defaultLineHeight || 0; items.push({ line: i + 1, top: getLineTop(i), height }); } return items; }, [ defaultLineHeight, getLineTop, lineNumbersVersion, showLineNumbersActive, visibleRange.end, visibleRange.start ]); 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 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 { textId } = await createText(DEFAULT_TITLE, "", null); await refreshTexts(); 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 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 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: () => { setConfirmState({ title: "Delete text", message: `Delete \"${text.title}\"? This removes all versions and drafts.`, actionLabel: "Delete text", onConfirm: () => handleDeleteText(text.id) }); } }, { text: "Move to folder", items } ] }); await menu.popup(undefined, getCurrentWindow()); }, [folderPathList, handleDeleteText, handleMoveTextToFolder, 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: () => { setConfirmState({ title: "Delete folder", message: "Delete this folder? Its subfolders and texts will move one level up.", actionLabel: "Delete folder", onConfirm: () => handleDeleteFolder(folder.id) }); } }, { text: "Move to folder", items: moveTargets } ] }); await menu.popup(undefined, getCurrentWindow()); }, [folderPathList, handleDeleteFolder, handleMoveFolderToFolder, isDescendantFolder, startEditingFolder] ); 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 { textId } = await createText(title, contents, null); await refreshTexts(); setSelectedTextId(textId); } catch (error) { console.error("Failed to open text file", error); } }, [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 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) => { 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 && !editingFolderId && !editingTextId ) { event.preventDefault(); setMarkdownPreview((value) => !value); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [confirmState, editingFolderId, editingTextId, handleSaveVersion, selectedTextId, 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} ) : (
{showLineNumbersActive ? ( ) : null} {showLineNumbersActive ? (
{lineNumbers.map((line, index) => (
{line}
))}
) : null} {markdownPreview ? (
) : (