Files
textDB/src/App.tsx

2585 lines
86 KiB
TypeScript
Raw Normal View History

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";
2026-02-01 04:24:10 +01:00
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";
2026-01-30 23:14:06 +01:00
import {
createFolder,
2026-01-30 23:14:06 +01:00
createText,
deleteFolder,
2026-01-30 23:14:06 +01:00
deleteText,
deleteTextVersion,
discardDraft,
exportDatabaseSnapshot,
2026-01-30 23:14:06 +01:00
getDraft,
getLatestManualVersion,
getText,
listFolders,
2026-01-30 23:14:06 +01:00
listTexts,
listVersions,
moveFolder,
moveTextToFolder,
2026-01-30 23:14:06 +01:00
saveManualVersion,
searchTexts,
updateFolderName,
2026-01-30 23:14:06 +01:00
updateTextTitle,
upsertDraft,
type Folder,
2026-01-30 23:14:06 +01:00
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`;
};
2026-01-30 23:14:06 +01:00
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> | void;
};
type DbExportStatus = {
tone: "success" | "error";
message: string;
};
2026-01-30 23:14:06 +01:00
type HistoryEntry = {
id: string;
created_at: number;
kind: "manual" | "draft";
body: string;
baseVersionId?: string | null;
};
type ConversionJob = {
actionLabel: string;
openPreviewOnSuccess: boolean;
sourceTextId: string;
sourceTitle: string;
sourceBody: string;
sourceDraftBaseVersionId: string | null;
controller: AbortController;
};
type DocumentStats = {
characters: number;
words: number;
sentences: number;
estimatedTokens: number;
};
type SidebarEntry =
| { kind: "folder"; item: Folder }
| { kind: "text"; item: Text };
type AiPromptKey =
| "markdownConversion"
| "proofreadSpelling"
| "summarize"
| "translate"
| "changeStyle"
| "rewrite";
type AiPrompts = Record<AiPromptKey, string>;
type AiPromptDefinition = {
key: AiPromptKey;
title: string;
hint?: string;
};
type AiActionRequest = {
promptKey: AiPromptKey;
actionLabel: string;
openPreviewOnSuccess?: boolean;
variables?: Record<string, string>;
};
2026-01-30 23:14:06 +01:00
const DEFAULT_TITLE = "Untitled Text";
const DEFAULT_FOLDER_NAME = "New Folder";
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
const DEFAULT_TRANSLATE_LANGUAGE = "English";
const DEFAULT_CHANGE_STYLE_PRESETS = ["Friendly", "Professional", "Polite", "Concise"];
const AI_PROMPTS_STORAGE_KEY = "textdb.aiPrompts";
const LEGACY_MARKDOWN_PROMPT_STORAGE_KEY = "textdb.ollamaPrompt";
const TRANSLATE_LANGUAGE_STORAGE_KEY = "textdb.translateLanguage";
const CHANGE_STYLE_PRESETS_STORAGE_KEY = "textdb.changeStylePresets";
const DEFAULT_AI_PROMPTS: AiPrompts = {
markdownConversion: `Convert the document into well-structured Markdown.
Do not remove, translate, summarize, or invent content.
Add Markdown formatting only where it improves structure and readability.
Return only the final Markdown document.`,
proofreadSpelling: `Proofread the document.
Correct spelling mistakes, obvious typos, and minor punctuation issues only.
Keep the wording, meaning, structure, and tone otherwise unchanged.
Return only the corrected document.`,
summarize: `Summarize the document into a concise version.
Keep the key facts, decisions, and important details.
Use clear structure and short paragraphs or bullets where helpful.
Return only the final summary.`,
translate: `Translate the document into {{language}}.
Preserve the meaning, intent, and structure as naturally as possible.
Keep Markdown formatting where it already exists or where it clearly improves readability.
Return only the translated document.`,
changeStyle: `Rewrite the document in a {{style}} style.
Preserve the meaning, facts, and overall structure.
Improve wording and tone to match the requested style without adding commentary.
Return only the rewritten document.`,
rewrite: `Rewrite the document for clarity and flow.
Preserve the meaning and important details.
Improve readability, structure, and phrasing without adding commentary.
Return only the rewritten document.`
};
const AI_PROMPT_DEFINITIONS: AiPromptDefinition[] = [
{
key: "markdownConversion",
title: "Markdown Conversion"
},
{
key: "proofreadSpelling",
title: "Proofread - Correct Spelling"
},
{
key: "summarize",
title: "Summarize"
},
{
key: "translate",
title: "Translate",
hint: "Uses {{language}}."
},
{
key: "changeStyle",
title: "Change Style",
hint: "Uses {{style}}."
},
{
key: "rewrite",
title: "Rewrite"
}
];
function loadAiPrompts(): AiPrompts {
let stored: Partial<AiPrompts> | null = null;
try {
const raw = localStorage.getItem(AI_PROMPTS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
stored = parsed as Partial<AiPrompts>;
}
}
} catch {
stored = null;
}
const legacyMarkdownPrompt = localStorage.getItem(LEGACY_MARKDOWN_PROMPT_STORAGE_KEY);
return {
...DEFAULT_AI_PROMPTS,
...stored,
markdownConversion:
stored?.markdownConversion ||
legacyMarkdownPrompt ||
DEFAULT_AI_PROMPTS.markdownConversion
};
}
function loadChangeStylePresets() {
try {
const raw = localStorage.getItem(CHANGE_STYLE_PRESETS_STORAGE_KEY);
if (!raw) return [...DEFAULT_CHANGE_STYLE_PRESETS];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_CHANGE_STYLE_PRESETS];
const normalized = parsed
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean);
return normalized.length > 0 ? Array.from(new Set(normalized)) : [...DEFAULT_CHANGE_STYLE_PRESETS];
} catch {
return [...DEFAULT_CHANGE_STYLE_PRESETS];
}
}
function applyPromptVariables(template: string, variables: Record<string, string>) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => variables[key] ?? `{{${key}}}`);
}
2026-01-30 23:14:06 +01:00
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;
}
2026-01-30 23:14:06 +01:00
export default function App() {
const [texts, setTexts] = useState<Text[]>([]);
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
2026-03-11 11:07:17 +01:00
const [textSearch, setTextSearch] = useState("");
2026-01-30 23:14:06 +01:00
const [loadingTexts, setLoadingTexts] = useState(true);
const [folders, setFolders] = useState<Folder[]>([]);
const [loadingFolders, setLoadingFolders] = useState(true);
2026-01-30 23:14:06 +01:00
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<string | null>(null);
const [draftBaseVersionId, setDraftBaseVersionId] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyItems, setHistoryItems] = useState<HistoryEntry[]>([]);
const [viewingVersion, setViewingVersion] = useState<HistoryEntry | null>(null);
const [selectedHistoryId, setSelectedHistoryId] = useState<string | null>(null);
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
2026-03-13 21:56:44 +01:00
const [dbExporting, setDbExporting] = useState(false);
const [dbExportStatus, setDbExportStatus] = useState<DbExportStatus | null>(null);
const [markdownPreview, setMarkdownPreview] = useState(false);
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingTextTitle, setEditingTextTitle] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => {
const stored = localStorage.getItem("textdb.expandedFolders");
if (!stored) return new Set();
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((value) => typeof value === "string"));
}
} catch {
return new Set();
}
return new Set();
});
const [theme, setTheme] = useState<"dark" | "light">(() => {
const storedTheme = localStorage.getItem("textdb.theme");
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
return storedTheme === "default" ? "dark" : "dark";
});
const [textSize, setTextSize] = useState(() => {
const storedSize = Number(localStorage.getItem("textdb.textSize"));
if (!Number.isNaN(storedSize) && storedSize >= 12 && storedSize <= 18) {
return storedSize;
}
return 16;
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
const stored = localStorage.getItem("textdb.lineNumbers");
return stored === null ? true : stored === "true";
});
const [splitView, setSplitView] = useState(() => {
const stored = localStorage.getItem("textdb.splitView");
return stored === null ? true : stored === "true";
});
const [ollamaUrl, setOllamaUrl] = useState(() => {
return localStorage.getItem("textdb.ollamaUrl") || DEFAULT_OLLAMA_URL;
});
const [ollamaModel, setOllamaModel] = useState(() => {
return localStorage.getItem("textdb.ollamaModel") || "";
});
const [aiPrompts, setAiPrompts] = useState<AiPrompts>(() => loadAiPrompts());
const [expandedPromptKey, setExpandedPromptKey] = useState<AiPromptKey | null>(
"markdownConversion"
);
const [translateLanguage, setTranslateLanguage] = useState(() => {
return localStorage.getItem(TRANSLATE_LANGUAGE_STORAGE_KEY) || DEFAULT_TRANSLATE_LANGUAGE;
});
const [changeStylePresets, setChangeStylePresets] = useState(() => loadChangeStylePresets());
const [newStylePreset, setNewStylePreset] = useState("");
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [ollamaLoading, setOllamaLoading] = useState(false);
const [ollamaError, setOllamaError] = useState<string | null>(null);
const [conversionJob, setConversionJob] = useState<ConversionJob | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
});
const [editorReady, setEditorReady] = useState(false);
2026-01-30 23:14:06 +01:00
const bodyRef = useRef(body);
const selectedTextIdRef = useRef<string | null>(selectedTextId);
const historyOpenRef = useRef(historyOpen);
const viewingVersionRef = useRef<HistoryEntry | null>(viewingVersion);
const editorViewRef = useRef<EditorView | null>(null);
const editorValueRef = useRef("");
const lineNumbersCompartmentRef = useRef(new Compartment());
const editableCompartmentRef = useRef(new Compartment());
2026-03-12 15:58:40 +01:00
const themeCompartmentRef = useRef(new Compartment());
2026-01-30 23:14:06 +01:00
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
const recentOpenRef = useRef(new Map<string, number>());
const searchRestoreSplitRef = useRef<boolean | null>(null);
const pendingEditorFocusRef = useRef(false);
const ignoreTextBlurRef = useRef(false);
const ignoreFolderBlurRef = useRef(false);
2026-01-30 23:14:06 +01:00
bodyRef.current = body;
selectedTextIdRef.current = selectedTextId;
historyOpenRef.current = historyOpen;
viewingVersionRef.current = viewingVersion;
2026-01-30 23:14:06 +01:00
useEffect(() => {
2026-02-01 04:35:11 +01:00
document.body.dataset.theme = theme === "dark" ? "default" : "light";
localStorage.setItem("textdb.theme", theme);
}, [theme]);
useEffect(() => {
document.documentElement.style.setProperty("--base-font-size", `${textSize}px`);
localStorage.setItem("textdb.textSize", String(textSize));
}, [textSize]);
useEffect(() => {
localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed));
}, [sidebarCollapsed]);
useEffect(() => {
localStorage.setItem("textdb.lineNumbers", String(showLineNumbers));
}, [showLineNumbers]);
useEffect(() => {
localStorage.setItem("textdb.splitView", String(splitView));
}, [splitView]);
useEffect(() => {
localStorage.setItem("textdb.ollamaUrl", ollamaUrl);
}, [ollamaUrl]);
useEffect(() => {
localStorage.setItem("textdb.ollamaModel", ollamaModel);
}, [ollamaModel]);
useEffect(() => {
localStorage.setItem(AI_PROMPTS_STORAGE_KEY, JSON.stringify(aiPrompts));
}, [aiPrompts]);
useEffect(() => {
localStorage.setItem(TRANSLATE_LANGUAGE_STORAGE_KEY, translateLanguage);
}, [translateLanguage]);
useEffect(() => {
localStorage.setItem(
CHANGE_STYLE_PRESETS_STORAGE_KEY,
JSON.stringify(changeStylePresets)
);
}, [changeStylePresets]);
useEffect(() => {
localStorage.setItem(
"textdb.expandedFolders",
JSON.stringify(Array.from(expandedFolders))
);
}, [expandedFolders]);
useEffect(() => {
if (selectedTextId) {
localStorage.setItem("textdb.selectedTextId", selectedTextId);
}
}, [selectedTextId]);
const editorThemeExtension = useMemo(
() =>
EditorView.theme(
{
".cm-content, .cm-scroller": {
caretColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f"
},
"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor": {
borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f",
borderLeftWidth: "1.6px"
},
".cm-dropCursor": {
borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f",
borderLeftWidth: "1.6px"
},
".cm-fatCursor": {
background: theme === "dark" ? "rgba(245, 245, 245, 0.45)" : "rgba(31, 31, 31, 0.28)"
}
},
{ dark: theme === "dark" }
),
[theme]
);
const editorHostRef = useCallback((node: HTMLDivElement | null) => {
if (!node) {
if (editorViewRef.current) {
editorViewRef.current.destroy();
editorViewRef.current = null;
setEditorReady(false);
}
return;
}
if (editorViewRef.current) return;
const state = EditorState.create({
doc: bodyRef.current,
extensions: [
EditorView.lineWrapping,
history(),
search(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
themeCompartmentRef.current.of(editorThemeExtension),
lineNumbersCompartmentRef.current.of([]),
editableCompartmentRef.current.of(EditorView.editable.of(true)),
EditorView.updateListener.of((update) => {
if (!searchPanelOpen(update.state) && searchRestoreSplitRef.current !== null) {
const nextSplitView = searchRestoreSplitRef.current;
searchRestoreSplitRef.current = null;
setSplitView(nextSplitView);
}
if (!update.docChanged) return;
const value = update.state.doc.toString();
editorValueRef.current = value;
setBody(value);
})
]
});
const view = new EditorView({
state,
parent: node
});
editorViewRef.current = view;
editorValueRef.current = bodyRef.current;
setEditorReady(true);
}, [editorThemeExtension]);
2026-01-30 23:14:06 +01:00
const isViewingHistory = viewingVersion !== null;
const isConverting = conversionJob !== null;
2026-01-30 23:14:06 +01:00
const isDirty = !isViewingHistory && body !== lastPersistedBody;
const hasText = body.trim().length > 0;
const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView);
2026-03-11 11:07:17 +01:00
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]);
2026-01-31 18:46:08 +01:00
const folderById = useMemo(() => {
const map = new Map<string, Folder>();
for (const folder of folders) {
map.set(folder.id, folder);
}
return map;
}, [folders]);
const visibleFolderIds = useMemo(() => {
if (!hasSearch) return null;
const visible = new Set<string>();
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]);
2026-01-30 23:14:06 +01:00
const entriesByParent = useMemo(() => {
const map = new Map<string | null, SidebarEntry[]>();
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<string, number>();
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<HTMLDivElement>) => {
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();
2026-02-01 05:10:23 +01:00
}, [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]);
2026-01-30 23:14:06 +01:00
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]);
2026-01-30 23:14:06 +01:00
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]);
2026-01-30 23:14:06 +01:00
const refreshTexts = useCallback(async () => {
setLoadingTexts(true);
try {
2026-03-11 11:07:17 +01:00
const trimmed = textSearch.trim();
2026-01-30 23:14:06 +01:00
const rows = trimmed ? await searchTexts(trimmed) : await listTexts();
setTexts(rows);
} finally {
setLoadingTexts(false);
}
2026-03-11 11:07:17 +01:00
}, [textSearch]);
2026-01-30 23:14:06 +01:00
2026-01-31 18:45:22 +01:00
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;
2026-01-30 23:14:06 +01:00
const [manualRows, draft] = await Promise.all([
listVersions(textId),
getDraft(textId)
2026-01-30 23:14:06 +01:00
]);
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}`,
2026-01-30 23:14:06 +01:00
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);
}, []);
2026-01-30 23:14:06 +01:00
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
]);
2026-01-30 23:14:06 +01:00
useEffect(() => {
refreshTexts().catch((error) => {
console.error("Failed to load texts", error);
});
}, [refreshTexts]);
useEffect(() => {
refreshFolders().catch((error) => {
console.error("Failed to load folders", error);
});
}, [refreshFolders]);
2026-01-30 23:14:06 +01:00
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);
2026-01-30 23:14:06 +01:00
}, [selectedTextId, texts]);
useEffect(() => {
if (!historyOpen) {
setHistoryItems([]);
return;
}
refreshVersions().catch((error) => {
console.error("Failed to load versions", error);
});
}, [historyOpen, refreshVersions, selectedTextId]);
2026-01-30 23:14:06 +01:00
useEffect(() => {
if (!selectedTextId) {
const view = editorViewRef.current;
if (view) {
closeSearchPanel(view);
}
searchRestoreSplitRef.current = null;
2026-01-30 23:14:06 +01:00
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");
2026-01-30 23:14:06 +01:00
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 () => {
2026-02-01 01:50:43 +01:00
const { textId } = await createText(DEFAULT_TITLE, "", null);
2026-01-30 23:14:06 +01:00
await refreshTexts();
pendingEditorFocusRef.current = true;
setMarkdownPreview(false);
2026-01-30 23:14:06 +01:00
setSelectedTextId(textId);
2026-02-01 01:50:43 +01:00
}, [refreshTexts]);
2026-01-30 23:14:06 +01:00
const handleNewFolder = useCallback(async () => {
2026-02-01 01:50:43 +01:00
const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null);
await refreshFolders();
setEditingTextId(null);
setEditingTextTitle("");
setEditingFolderId(folderId);
setEditingFolderName(DEFAULT_FOLDER_NAME);
2026-02-01 01:50:43 +01:00
}, [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<string>();
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<string[]>("file-opened", async (event) => {
await handleFilePaths(event.payload ?? []);
})
.then((cleanup) => {
unlisten = cleanup;
})
.catch((error) => {
console.error("Failed to register file-open listener", error);
});
invoke<string[]>("take_pending_opens")
.then((paths) => handleFilePaths(paths ?? []))
.catch((error) => {
console.error("Failed to load pending file opens", error);
});
return () => {
if (unlisten) unlisten();
};
}, [handleFilePaths]);
const handleOpenText = useCallback(async () => {
const baseDir = await appDataDir();
const path = await open({
multiple: false,
directory: false,
defaultPath: baseDir
});
if (!path || Array.isArray(path)) return;
await createTextFromFile(path);
}, [createTextFromFile]);
const handleExportDatabase = useCallback(async () => {
setDbExportStatus(null);
const selectedPath = await save({
defaultPath: buildDatabaseExportFilename(),
filters: [{ name: "SQLite Database", extensions: ["db", "sqlite"] }]
});
if (!selectedPath) return;
const destinationPath =
selectedPath.endsWith(".db") || selectedPath.endsWith(".sqlite")
? selectedPath
: `${selectedPath}.db`;
setDbExporting(true);
let snapshotPath: string | null = null;
try {
if (selectedTextId && isDirty && !isViewingHistory) {
await upsertDraft(selectedTextId, body, draftBaseVersionId);
setHasDraft(true);
setLastPersistedBody(body);
setSelectedHistoryId(`draft:${selectedTextId}`);
if (historyOpen) {
await refreshVersions();
}
}
const baseDir = await appDataDir();
const sourcePath = await join(baseDir, "text.db");
snapshotPath = await join(baseDir, `textdb-export-${Date.now()}.db`);
if (destinationPath === sourcePath) {
throw new Error("Choose a different destination than the live database file.");
}
if (await exists(snapshotPath)) {
await remove(snapshotPath);
}
if (await exists(destinationPath)) {
await remove(destinationPath);
}
await exportDatabaseSnapshot(snapshotPath);
await copyFile(snapshotPath, destinationPath);
setDbExportStatus({
tone: "success",
message: `Database exported to ${destinationPath}`
});
} catch (error) {
console.error("Failed to export database", error);
setDbExportStatus({
tone: "error",
message: error instanceof Error ? error.message : "Failed to export database."
});
} finally {
if (snapshotPath && (await exists(snapshotPath).catch(() => false))) {
await remove(snapshotPath).catch(() => {});
}
setDbExporting(false);
}
}, [
body,
draftBaseVersionId,
historyOpen,
isDirty,
isViewingHistory,
refreshVersions,
selectedTextId
]);
2026-01-30 23:14:06 +01:00
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();
2026-01-31 18:54:10 +01:00
setTexts((prev) =>
prev.map((text) =>
text.id === promptId ? { ...text, title: nextTitle, updated_at: now } : text
)
);
2026-01-30 23:14:06 +01:00
}, []);
const handleTitleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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;
}
}
2026-01-30 23:14:06 +01:00
const isSave =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
2026-01-31 16:58:30 +01:00
if (isSave) {
event.preventDefault();
handleSaveVersion().catch((error) => {
console.error("Failed to save version", error);
});
return;
}
if (event.key === "Tab") return;
2026-01-30 23:14:06 +01:00
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmState, handleSaveVersion, openDocumentSearch, settingsOpen]);
2026-01-30 23:14:06 +01:00
const renderTextItem = (text: Text) => (
<div
key={text.id}
className={`prompt-item${text.id === selectedTextId ? " is-active" : ""}`}
onClick={() => {
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);
}
}}
>
<div className="prompt-item__content">
{editingTextId === text.id ? (
<input
className="prompt-item__input"
value={editingTextTitle}
onChange={(event) => 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
/>
) : (
<div className="prompt-item__title">{text.title}</div>
)}
</div>
<button
className="prompt-item__delete"
onClick={(event) => {
event.stopPropagation();
requestDeleteText(text).catch((error) => {
console.error("Failed to delete text", error);
});
}}
aria-label="Delete text"
title="Delete text"
>
×
</button>
</div>
);
const renderFolder = (folder: Folder) => {
if (hasSearch && !visibleFolderIds?.has(folder.id)) return null;
const expanded = isFolderExpanded(folder.id);
const childEntries = entriesByParent.get(folder.id) ?? [];
return (
<div key={folder.id} className="folder-node">
<div
className={`folder-item${expanded ? " is-open" : ""}`}
onClick={() => {
if (editingFolderId === folder.id) return;
toggleFolderExpanded(folder.id);
}}
onContextMenu={(event) => handleFolderContextMenu(event, folder)}
>
2026-02-01 02:00:15 +01:00
<div className="folder-item__lead">
<button
className="folder-item__toggle"
type="button"
aria-label={expanded ? "Collapse folder" : "Expand folder"}
onClick={(event) => {
event.stopPropagation();
toggleFolderExpanded(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
<img src={folderIconSrc} alt="" className="folder-item__icon" />
</div>
{editingFolderId === folder.id ? (
<input
className="folder-item__input"
value={editingFolderName}
onChange={(event) => 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
/>
) : (
<div className="folder-item__title">{folder.name}</div>
)}
2026-02-01 01:09:48 +01:00
<button
className="folder-item__delete"
onClick={(event) => {
event.stopPropagation();
requestDeleteFolder(folder).catch((error) => {
console.error("Failed to delete folder", error);
2026-02-01 01:09:48 +01:00
});
}}
aria-label="Delete folder"
title="Delete folder"
>
×
</button>
</div>
{expanded && childEntries.length > 0 ? (
<div className="folder-children">
{childEntries.map((entry) =>
entry.kind === "folder"
? renderFolder(entry.item)
: renderTextItem(entry.item)
)}
</div>
) : null}
</div>
);
};
2026-01-30 23:14:06 +01:00
return (
<div className={`app app--theme-${theme}${sidebarCollapsed ? " app--sidebar-collapsed" : ""}`}>
{!sidebarCollapsed ? (
<aside className="sidebar">
<div className="sidebar__header">
<div className="sidebar__title-row">
<div className="app-title">TextDB</div>
</div>
<input
className="search"
placeholder="Search texts"
2026-03-11 11:07:17 +01:00
value={textSearch}
onChange={(event) => setTextSearch(event.target.value)}
/>
</div>
<div className="prompt-list">
<div className="prompt-list__inner">
{loadingTexts || loadingFolders ? (
<div className="empty">Loading</div>
) : hasSearch && texts.length === 0 ? (
<div className="empty">No matching texts.</div>
) : texts.length === 0 && folders.length === 0 ? (
<div className="empty">No texts yet.</div>
) : (
<>
{(entriesByParent.get(null) ?? []).map((entry) =>
entry.kind === "folder"
? renderFolder(entry.item)
: renderTextItem(entry.item)
)}
</>
)}
</div>
</div>
<div className="sidebar__footer">
<button
className="icon-button"
onClick={handleOpenText}
aria-label="Open text"
title="Open text"
type="button"
>
<img src={openFileIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={handleNewText}
aria-label="New text"
title="New text"
type="button"
>
<img src={newTextIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={handleNewFolder}
aria-label="New folder"
title="New folder"
type="button"
>
<img src={newFolderIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
title="Settings"
type="button"
>
<img src={settingsIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={() => setSidebarCollapsed(true)}
aria-label="Collapse sidebar"
title="Collapse sidebar"
type="button"
>
<img src={sidebarCollapseIconSrc} alt="" className="icon-button__img" />
</button>
</div>
</aside>
) : null}
2026-01-30 23:14:06 +01:00
<main className="workspace">
<div className="workspace__body">
{!selectedTextId ? (
<>
<div className="empty-state">
<div className="empty-state__title">Create your first text</div>
<div className="empty-state__subtitle">
Everything stays offline in a single SQLite database.
</div>
<button className="button button--primary" onClick={handleNewText}>
New Text
</button>
</div>
{sidebarCollapsed ? (
<div className="editor__footer">
<button
className="icon-button"
onClick={() => setSidebarCollapsed(false)}
aria-label="Expand sidebar"
title="Expand sidebar"
type="button"
>
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
</button>
</div>
) : null}
</>
) : (
<div
className={`workspace__content${
historyOpen ? " workspace__content--history" : ""
}`}
>
2026-01-30 23:14:06 +01:00
<section className="editor">
<div className="editor__header">
2026-01-31 13:37:41 +01:00
<div className="editor__title-row">
<input
className="title-input"
value={title}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Text title"
disabled={isViewingHistory}
/>
2026-01-30 23:14:06 +01:00
<button
className={`icon-button${historyOpen ? " is-active" : ""}`}
onClick={handleToggleHistory}
aria-label={historyOpen ? "Close history" : "Open history"}
title={historyOpen ? "Close history" : "Open history"}
type="button"
>
<img src={historyIconSrc} alt="" className="icon-button__img" />
2026-01-30 23:14:06 +01:00
</button>
</div>
</div>
<div
className={`editor__textarea-wrap${
markdownPreview && !splitView ? " editor__textarea-wrap--preview" : ""
}${markdownPreview && splitView ? " editor__textarea-wrap--split" : ""}`}
>
<div
ref={editorHostRef}
className={`editor__codemirror${
markdownPreview && !splitView ? " editor__codemirror--hidden" : ""
}`}
/>
<div
className={`markdown-preview md-root${markdownPreview ? "" : " markdown-preview--hidden"}`}
dangerouslySetInnerHTML={{ __html: markdownHtml }}
onClick={handleMarkdownPreviewClick}
/>
</div>
2026-01-30 23:14:06 +01:00
<div className="editor__footer">
{sidebarCollapsed ? (
<button
className="icon-button"
onClick={() => setSidebarCollapsed(false)}
aria-label="Expand sidebar"
title="Expand sidebar"
type="button"
>
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
</button>
) : null}
{hasText || isConverting ? (
<>
{hasText ? (
<button
className="button"
type="button"
onClick={() => setMarkdownPreview((value) => !value)}
>
{markdownPreview
? splitView
? "Hide Preview"
: "Edit"
: "Preview Markdown"}
</button>
) : null}
<button
className="button"
type="button"
onClick={isConverting ? handleCancelConversion : handleConvertToMarkdown}
disabled={isConverting ? false : !ollamaModel || isViewingHistory || !hasText}
>
{isConverting ? "Cancel Conversion" : "Convert to Markdown"}
</button>
{hasText ? (
<button className="button" onClick={handleExportText}>
Export Text
</button>
) : null}
{hasText && markdownPreview ? (
<button className="button" type="button" onClick={handlePrintMarkdown}>
Print
</button>
) : null}
</>
2026-01-30 23:14:06 +01:00
) : null}
{hasDraft && !isViewingHistory ? (
<button
className="button"
onClick={() =>
setConfirmState({
title: "Discard draft",
message: "Discard this draft? This cannot be undone.",
actionLabel: "Discard draft",
onConfirm: handleDiscardDraft
})
}
>
Discard Draft
</button>
) : null}
2026-01-31 13:37:41 +01:00
<div className="editor__footer-status">
<div className="status-line">
<span className={`status status--${statusKey}`}></span>
{statusLabel}
</div>
{conversionLabel ? (
<div className="status-line status-line--secondary">{conversionLabel}</div>
) : null}
<div className="editor__stats" aria-label="Document statistics">
<span>{documentStats.characters} chars</span>
<span>{documentStats.words} words</span>
<span>{documentStats.sentences} sentences</span>
<span>{documentStats.estimatedTokens} est. tokens</span>
</div>
2026-01-31 13:37:41 +01:00
</div>
2026-01-30 23:14:06 +01:00
<button
className="button button--primary button--save"
onClick={handleSaveVersion}
disabled={!canSave}
>
Save Version (S)
</button>
</div>
</section>
{historyOpen ? (
<aside className="history">
<div className="history__header">
<span>History</span>
<button
className="history__close"
onClick={handleToggleHistory}
aria-label="Close history"
title="Close history"
type="button"
>
×
</button>
</div>
<div className="history__list">
{historyItems.length === 0 ? (
<div className="empty">No versions yet.</div>
) : (
historyItems.map((version) => (
<div
key={version.id}
className={`history__item${
selectedHistoryId === version.id ? " is-active" : ""
}`}
onClick={() => applyVersionAsCurrent(version)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
applyVersionAsCurrent(version);
}
}}
>
<div className="history__item-content">
<div className="history__item-title">
{formatDate(version.created_at)}
</div>
<div className="history__item-meta">
{version.kind === "draft" ? "Draft" : "Manual save"}
</div>
</div>
<button
className="history__item-delete"
onClick={(event) => {
event.stopPropagation();
setConfirmState({
title:
version.kind === "draft"
? "Discard draft"
: "Delete version",
message:
version.kind === "draft"
? "Discard this draft? This cannot be undone."
: "Delete this version? This cannot be undone.",
actionLabel:
version.kind === "draft"
? "Discard draft"
: "Delete version",
onConfirm: () => handleDeleteVersion(version)
});
}}
aria-label={
version.kind === "draft"
? "Discard draft"
: "Delete version"
}
title={
version.kind === "draft"
? "Discard draft"
: "Delete version"
}
>
×
</button>
</div>
))
)}
</div>
</aside>
) : null}
</div>
)}
</div>
2026-01-30 23:14:06 +01:00
</main>
{settingsOpen ? (
<div className="settings-overlay">
<div
className="settings-overlay__backdrop"
onClick={() => setSettingsOpen(false)}
/>
<div className="settings-panel" role="dialog" aria-modal="true">
<div className="settings-panel__header">
<div className="settings-panel__title">Settings</div>
<button
className="icon-button icon-button--ghost"
onClick={() => setSettingsOpen(false)}
aria-label="Close settings"
title="Close settings"
type="button"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="settings-panel__body">
<div className="settings-panel__section-title">Interface</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="theme-select">
Theme
</label>
<select
id="theme-select"
className="settings-panel__select"
value={theme}
onChange={(event) =>
setTheme(event.target.value as "dark" | "light")
}
>
<option value="dark">Dark</option>
<option value="light">Bright</option>
</select>
</div>
<div className="settings-panel__section settings-panel__section--row">
<label className="settings-panel__label" htmlFor="line-numbers-toggle">
Line numbers
</label>
<input
id="line-numbers-toggle"
type="checkbox"
checked={showLineNumbers}
onChange={(event) => setShowLineNumbers(event.target.checked)}
/>
</div>
<div className="settings-panel__section settings-panel__section--row">
<label className="settings-panel__label" htmlFor="split-view-toggle">
Split view
</label>
<input
id="split-view-toggle"
type="checkbox"
checked={splitView}
onChange={(event) => setSplitView(event.target.checked)}
/>
</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="text-size">
Text size
</label>
<div className="settings-panel__slider-row">
<input
id="text-size"
className="settings-panel__range"
type="range"
min={12}
max={18}
step={1}
value={textSize}
onChange={(event) => setTextSize(Number(event.target.value))}
/>
<div className="settings-panel__value">{textSize}px</div>
</div>
</div>
<div className="settings-panel__section-title">Ollama</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="ollama-url">
URL
</label>
<input
id="ollama-url"
className="settings-panel__input"
type="text"
value={ollamaUrl}
onChange={(event) => setOllamaUrl(event.target.value)}
placeholder={DEFAULT_OLLAMA_URL}
/>
</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="ollama-model">
Model
</label>
<select
id="ollama-model"
className="settings-panel__select"
value={ollamaModel}
onChange={(event) => setOllamaModel(event.target.value)}
disabled={ollamaLoading}
>
{ollamaModels.length > 0 ? (
ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))
) : ollamaModel ? (
<option value={ollamaModel}>{ollamaModel}</option>
) : (
<option value="">
{ollamaLoading ? "Loading models…" : "No models found"}
2026-02-01 05:10:00 +01:00
</option>
)}
</select>
{ollamaError ? (
<div className="settings-panel__hint">{ollamaError}</div>
) : null}
</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="ollama-prompt">
Prompt
</label>
<textarea
id="ollama-prompt"
className="settings-panel__textarea"
value={ollamaPrompt}
onChange={(event) => setOllamaPrompt(event.target.value)}
rows={6}
/>
</div>
<div className="settings-panel__section-title">Export</div>
<div className="settings-panel__section">
<button
className="button"
onClick={() => {
handleExportDatabase().catch((error) => {
console.error("Failed to export database", error);
});
}}
disabled={dbExporting}
type="button"
>
{dbExporting ? "Exporting…" : "Export DB"}
</button>
<div className="settings-panel__hint">
Save a backup copy of the current SQLite database.
</div>
{dbExportStatus ? (
<div
className={`settings-panel__status settings-panel__status--${dbExportStatus.tone}`}
>
{dbExportStatus.message}
</div>
) : null}
</div>
2026-02-01 05:10:00 +01:00
</div>
</div>
</div>
) : null}
2026-01-30 23:14:06 +01:00
{confirmState ? (
<div className="modal">
<div className="modal__overlay" onClick={() => setConfirmState(null)} />
<div className="modal__card" role="dialog" aria-modal="true">
<div className="modal__title">{confirmState.title}</div>
<div className="modal__message">{confirmState.message}</div>
<div className="modal__actions">
<button className="button" onClick={() => setConfirmState(null)}>
Cancel
</button>
<button className="button button--danger" onClick={handleConfirm}>
{confirmState.actionLabel ?? "Delete"}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}