Files
textDB/src/App.tsx

3108 lines
103 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import { copyFile, exists, readTextFile, remove, writeTextFile } from "@tauri-apps/plugin-fs";
import { Menu } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { appDataDir, join } from "@tauri-apps/api/path";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { Compartment, EditorState, Transaction } from "@codemirror/state";
import {
closeSearchPanel,
openSearchPanel,
search,
searchKeymap,
searchPanelOpen
} from "@codemirror/search";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLineGutter
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import folderIcon from "../src-tauri/icons/folder.png";
import folderIconBright from "../src-tauri/icons/folder_b.png";
import historyIcon from "../src-tauri/icons/history.png";
import historyIconBright from "../src-tauri/icons/history_b.png";
import newFolderIcon from "../src-tauri/icons/new_folder.png";
import newFolderIconBright from "../src-tauri/icons/new_folder_b.png";
import newTextIcon from "../src-tauri/icons/new_text.png";
import newTextIconBright from "../src-tauri/icons/new_text_b.png";
import openFileIcon from "../src-tauri/icons/open_file.png";
import openFileIconBright from "../src-tauri/icons/open_file_b.png";
import settingsIcon from "../src-tauri/icons/settings.png";
import settingsIconBright from "../src-tauri/icons/settings_b.png";
import sidebarCollapseIcon from "../src-tauri/icons/sb_collapse.png";
import sidebarCollapseIconBright from "../src-tauri/icons/sb_collapse_b.png";
import sidebarExpandIcon from "../src-tauri/icons/sb_expand.png";
import sidebarExpandIconBright from "../src-tauri/icons/sb_expand_b.png";
import { markdownToHTML } from "./markdown/markdown";
import "./markdown/markdown-render.css";
import {
createFolder,
createText,
deleteFolder,
deleteText,
deleteTextVersion,
discardDraft,
exportDatabaseSnapshot,
getDraft,
getLatestManualVersion,
getText,
listFolders,
listTexts,
listVersions,
moveFolder,
moveTextToFolder,
saveManualVersion,
searchTexts,
updateFolderName,
updateTextTitle,
upsertDraft,
type Folder,
type Text,
} from "./lib/db";
const formatDate = (timestamp: number) => {
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
};
const buildDatabaseExportFilename = () => {
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
return `textdb-backup-${timestamp}.db`;
};
type HistorySnapshot = {
body: string;
lastPersistedBody: string;
lastPersistedTitle: string;
hasDraft: boolean;
restoredDraft: boolean;
draftBaseVersionId: string | null;
latestManualVersionId: string | null;
};
type ConfirmState = {
title: string;
message: string;
actionLabel?: string;
onConfirm: () => Promise<void> | void;
};
type DbExportStatus = {
tone: "success" | "error";
message: string;
};
type HistoryEntry = {
id: string;
created_at: number;
kind: "manual" | "draft";
body: string;
baseVersionId?: string | null;
};
type ConversionJob = {
actionLabel: string;
openPreviewOnSuccess: boolean;
sourceTextId: string;
sourceTitle: string;
sourceBody: string;
sourceDraftBaseVersionId: string | null;
controller: AbortController;
};
type DocumentStats = {
characters: number;
words: number;
sentences: number;
estimatedTokens: number;
};
type SidebarEntry =
| { kind: "folder"; item: Folder }
| { kind: "text"; item: Text };
type AiPromptTemplate = {
id: string;
title: string;
prompt: string;
openPreviewOnSuccess?: boolean;
};
type AiEditScope = "document" | "selection";
type AiSelection = {
from: number;
to: number;
text: string;
};
type AiActionRequest = {
template: AiPromptTemplate;
scope?: AiEditScope;
selection?: AiSelection | null;
};
type PendingAiScopeChoice = {
template: AiPromptTemplate | null;
selection: AiSelection;
isCustomPrompt: boolean;
};
type CustomPromptState = {
scope: AiEditScope;
selection: AiSelection | null;
};
const DEFAULT_TITLE = "Untitled Text";
const DEFAULT_FOLDER_NAME = "New Folder";
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
const AI_PROMPT_TEMPLATES_STORAGE_KEY = "textdb.aiPromptTemplates";
const LEGACY_AI_PROMPTS_STORAGE_KEY = "textdb.aiPrompts";
const LEGACY_MARKDOWN_PROMPT_STORAGE_KEY = "textdb.ollamaPrompt";
const LEGACY_TRANSLATE_LANGUAGE_STORAGE_KEY = "textdb.translateLanguage";
const LEGACY_CHANGE_STYLE_PRESETS_STORAGE_KEY = "textdb.changeStylePresets";
const DEFAULT_AI_PROMPT_TEMPLATES: AiPromptTemplate[] = [
{
id: "markdown-conversion",
title: "Markdown Conversion",
prompt: `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.`,
openPreviewOnSuccess: true
},
{
id: "proofread-spelling",
title: "Proofread - Correct Spelling",
prompt: `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.`
},
{
id: "summarize",
title: "Summarize",
prompt: `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.`
},
{
id: "translate-english",
title: "Translate to English",
prompt: `Translate the document into English.
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.`
},
{
id: "change-style-friendly",
title: "Change Style: Friendly",
prompt: `Rewrite the document in a friendly 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.`
},
{
id: "change-style-professional",
title: "Change Style: Professional",
prompt: `Rewrite the document in a professional 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.`
},
{
id: "change-style-polite",
title: "Change Style: Polite",
prompt: `Rewrite the document in a polite 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.`
},
{
id: "change-style-concise",
title: "Change Style: Concise",
prompt: `Rewrite the document in a concise style.
Preserve the meaning, facts, and overall structure.
Make the writing tighter and more direct without losing important details.
Return only the rewritten document.`
},
{
id: "rewrite",
title: "Rewrite",
prompt: `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.`
}
];
function buildLegacyStylePrompt(prompt: string, style: string) {
return prompt.replace(/\{\{style\}\}/g, style);
}
function buildLegacyTranslatePrompt(prompt: string, language: string) {
return prompt.replace(/\{\{language\}\}/g, language);
}
function normalizeAiPromptTemplates(value: unknown): AiPromptTemplate[] {
if (!Array.isArray(value)) return [];
const normalized = value
.map((entry, index) => {
if (!entry || typeof entry !== "object") return null;
const record = entry as Record<string, unknown>;
const id =
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: `ai-template-${index}`;
const title =
typeof record.title === "string" && record.title.trim()
? record.title
: "Untitled Prompt";
const prompt = typeof record.prompt === "string" ? record.prompt : "";
return {
id,
title,
prompt,
openPreviewOnSuccess: Boolean(record.openPreviewOnSuccess)
} satisfies AiPromptTemplate;
})
.filter((entry): entry is AiPromptTemplate => entry !== null);
const uniqueIds = new Set<string>();
return normalized.filter((entry) => {
if (uniqueIds.has(entry.id)) return false;
uniqueIds.add(entry.id);
return true;
});
}
function loadLegacyStylePresets() {
try {
const raw = localStorage.getItem(LEGACY_CHANGE_STYLE_PRESETS_STORAGE_KEY);
if (!raw) return ["Friendly", "Professional", "Polite", "Concise"];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return ["Friendly", "Professional", "Polite", "Concise"];
const normalized = parsed
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean);
return normalized.length > 0
? Array.from(new Set(normalized))
: ["Friendly", "Professional", "Polite", "Concise"];
} catch {
return ["Friendly", "Professional", "Polite", "Concise"];
}
}
function loadAiPromptTemplates(): AiPromptTemplate[] {
try {
const raw = localStorage.getItem(AI_PROMPT_TEMPLATES_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const normalized = normalizeAiPromptTemplates(parsed);
if (normalized.length > 0) {
return normalized;
}
}
} catch {
// Fall through to legacy migration/defaults.
}
let legacyPrompts: Record<string, string> = {};
try {
const raw = localStorage.getItem(LEGACY_AI_PROMPTS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
legacyPrompts = parsed as Record<string, string>;
}
}
} catch {
legacyPrompts = {};
}
const legacyMarkdownPrompt =
legacyPrompts.markdownConversion ||
localStorage.getItem(LEGACY_MARKDOWN_PROMPT_STORAGE_KEY) ||
DEFAULT_AI_PROMPT_TEMPLATES[0].prompt;
const legacyTranslateLanguage =
localStorage.getItem(LEGACY_TRANSLATE_LANGUAGE_STORAGE_KEY)?.trim() || "English";
const legacyStylePromptsBase =
legacyPrompts.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.`;
const legacyTranslatePromptBase =
legacyPrompts.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.`;
const styleTemplates = loadLegacyStylePresets().map((style) => ({
id: `change-style-${style.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
title: `Change Style: ${style}`,
prompt: buildLegacyStylePrompt(legacyStylePromptsBase, style)
}));
return [
{
...DEFAULT_AI_PROMPT_TEMPLATES[0],
prompt: legacyMarkdownPrompt
},
{
...DEFAULT_AI_PROMPT_TEMPLATES[1],
prompt:
legacyPrompts.proofreadSpelling ||
DEFAULT_AI_PROMPT_TEMPLATES[1].prompt
},
{
...DEFAULT_AI_PROMPT_TEMPLATES[2],
prompt: legacyPrompts.summarize || DEFAULT_AI_PROMPT_TEMPLATES[2].prompt
},
{
id: `translate-${legacyTranslateLanguage.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
title: `Translate to ${legacyTranslateLanguage}`,
prompt: buildLegacyTranslatePrompt(legacyTranslatePromptBase, legacyTranslateLanguage)
},
...styleTemplates,
{
...DEFAULT_AI_PROMPT_TEMPLATES[8],
prompt: legacyPrompts.rewrite || DEFAULT_AI_PROMPT_TEMPLATES[8].prompt
}
];
}
function getAiPromptTemplateLabel(template: AiPromptTemplate) {
return template.title.trim() || "Untitled Prompt";
}
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
const wordSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "word" })
: null;
const sentenceSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "sentence" })
: null;
function getDocumentStats(text: string): DocumentStats {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const characters = graphemeSegmenter
? Array.from(graphemeSegmenter.segment(normalized)).length
: Array.from(normalized).length;
const words = wordSegmenter
? Array.from(wordSegmenter.segment(normalized)).filter((segment) => segment.isWordLike).length
: (normalized.match(/\b[\p{L}\p{N}][\p{L}\p{N}'-]*/gu) ?? []).length;
const sentences = sentenceSegmenter
? Array.from(sentenceSegmenter.segment(normalized)).filter(
(segment) => segment.segment.trim().length > 0
).length
: (normalized.match(/[^.!?]+(?:[.!?]+|$)/g) ?? [])
.map((segment) => segment.trim())
.filter(Boolean).length;
return {
characters,
words,
sentences,
estimatedTokens: Math.round((words * 4) / 3)
};
}
function getImportedTitle(filePath: string) {
const filename = filePath.split(/[\\/]/).pop()?.trim() || DEFAULT_TITLE;
const lastDot = filename.lastIndexOf(".");
if (lastDot <= 0) return filename || DEFAULT_TITLE;
const stripped = filename.slice(0, lastDot).trim();
return stripped || filename || DEFAULT_TITLE;
}
function getFileLabel(filePath: string) {
return filePath.split(/[\\/]/).pop()?.trim() || filePath;
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
export default function App() {
const [texts, setTexts] = useState<Text[]>([]);
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
const [textSearch, setTextSearch] = useState("");
const [loadingTexts, setLoadingTexts] = useState(true);
const [folders, setFolders] = useState<Folder[]>([]);
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<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);
const [pendingAiScopeChoice, setPendingAiScopeChoice] =
useState<PendingAiScopeChoice | null>(null);
const [customPromptState, setCustomPromptState] = useState<CustomPromptState | null>(null);
const [customPromptText, setCustomPromptText] = useState("");
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 [aiPromptTemplates, setAiPromptTemplates] = useState<AiPromptTemplate[]>(
() => loadAiPromptTemplates()
);
const [expandedPromptId, setExpandedPromptId] = useState<string | null>(null);
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);
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());
const themeCompartmentRef = useRef(new Compartment());
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);
bodyRef.current = body;
selectedTextIdRef.current = selectedTextId;
historyOpenRef.current = historyOpen;
viewingVersionRef.current = viewingVersion;
useEffect(() => {
document.body.dataset.theme = theme === "dark" ? "default" : "light";
localStorage.setItem("textdb.theme", theme);
}, [theme]);
useEffect(() => {
document.documentElement.style.setProperty("--base-font-size", `${textSize}px`);
localStorage.setItem("textdb.textSize", String(textSize));
}, [textSize]);
useEffect(() => {
localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed));
}, [sidebarCollapsed]);
useEffect(() => {
localStorage.setItem("textdb.lineNumbers", String(showLineNumbers));
}, [showLineNumbers]);
useEffect(() => {
localStorage.setItem("textdb.splitView", String(splitView));
}, [splitView]);
useEffect(() => {
localStorage.setItem("textdb.ollamaUrl", ollamaUrl);
}, [ollamaUrl]);
useEffect(() => {
localStorage.setItem("textdb.ollamaModel", ollamaModel);
}, [ollamaModel]);
useEffect(() => {
localStorage.setItem(
AI_PROMPT_TEMPLATES_STORAGE_KEY,
JSON.stringify(aiPromptTemplates)
);
}, [aiPromptTemplates]);
useEffect(() => {
if (aiPromptTemplates.length === 0) {
if (expandedPromptId !== null) {
setExpandedPromptId(null);
}
return;
}
if (
expandedPromptId === null ||
!aiPromptTemplates.some((template) => template.id === expandedPromptId)
) {
setExpandedPromptId(aiPromptTemplates[0].id);
}
}, [aiPromptTemplates, expandedPromptId]);
useEffect(() => {
localStorage.setItem(
"textdb.expandedFolders",
JSON.stringify(Array.from(expandedFolders))
);
}, [expandedFolders]);
useEffect(() => {
if (selectedTextId) {
localStorage.setItem("textdb.selectedTextId", selectedTextId);
}
}, [selectedTextId]);
const editorThemeExtension = useMemo(
() =>
EditorView.theme(
{
".cm-content, .cm-scroller": {
caretColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f"
},
"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor": {
borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f",
borderLeftWidth: "1.6px"
},
".cm-dropCursor": {
borderLeftColor: theme === "dark" ? "#f5f5f5" : "#1f1f1f",
borderLeftWidth: "1.6px"
},
".cm-fatCursor": {
background: theme === "dark" ? "rgba(245, 245, 245, 0.45)" : "rgba(31, 31, 31, 0.28)"
}
},
{ dark: theme === "dark" }
),
[theme]
);
const editorHostRef = useCallback((node: HTMLDivElement | null) => {
if (!node) {
if (editorViewRef.current) {
editorViewRef.current.destroy();
editorViewRef.current = null;
setEditorReady(false);
}
return;
}
if (editorViewRef.current) return;
const state = EditorState.create({
doc: bodyRef.current,
extensions: [
EditorView.lineWrapping,
history(),
search(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
themeCompartmentRef.current.of(editorThemeExtension),
lineNumbersCompartmentRef.current.of([]),
editableCompartmentRef.current.of(EditorView.editable.of(true)),
EditorView.updateListener.of((update) => {
if (!searchPanelOpen(update.state) && searchRestoreSplitRef.current !== null) {
const nextSplitView = searchRestoreSplitRef.current;
searchRestoreSplitRef.current = null;
setSplitView(nextSplitView);
}
if (!update.docChanged) return;
const value = update.state.doc.toString();
editorValueRef.current = value;
setBody(value);
})
]
});
const view = new EditorView({
state,
parent: node
});
editorViewRef.current = view;
editorValueRef.current = bodyRef.current;
setEditorReady(true);
}, [editorThemeExtension]);
const isViewingHistory = viewingVersion !== null;
const isConverting = conversionJob !== null;
const isDirty = !isViewingHistory && body !== lastPersistedBody;
const hasText = body.trim().length > 0;
const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView);
const hasSearch = textSearch.trim().length > 0;
const documentStats = useMemo(() => getDocumentStats(body), [body]);
const markdownHtml = useMemo(
() => (markdownPreview ? markdownToHTML(body) : ""),
[body, markdownPreview]
);
const normalizedOllamaUrl = useMemo(() => {
const trimmed = ollamaUrl.trim();
return (trimmed || DEFAULT_OLLAMA_URL).replace(/\/+$/, "");
}, [ollamaUrl]);
const folderById = useMemo(() => {
const map = new Map<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]);
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();
}, [normalizedOllamaUrl, settingsOpen]);
useEffect(() => {
if (!settingsOpen) return;
setDbExportStatus(null);
}, [settingsOpen]);
const handlePrintMarkdown = useCallback(() => {
if (!markdownPreview) return;
document.body.classList.add("print-markdown");
const cleanup = () => {
document.body.classList.remove("print-markdown");
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
requestAnimationFrame(() => {
window.print();
});
}, [markdownPreview]);
const openDocumentSearch = useCallback(() => {
if (!selectedTextIdRef.current) return false;
const revealEditor = markdownPreview && !splitView;
if (revealEditor) {
searchRestoreSplitRef.current = false;
setSplitView(true);
} else {
searchRestoreSplitRef.current = null;
}
window.requestAnimationFrame(() => {
const view = editorViewRef.current;
if (!view) return;
openSearchPanel(view);
view.focus();
});
return true;
}, [markdownPreview, splitView]);
const handleCancelConversion = useCallback(() => {
conversionJob?.controller.abort();
}, [conversionJob]);
const handleUpdateAiPromptTemplate = useCallback((
templateId: string,
field: "title" | "prompt",
value: string
) => {
setAiPromptTemplates((current) =>
current.map((template) =>
template.id === templateId
? {
...template,
[field]: value
}
: template
)
);
}, []);
const handleAddAiPromptTemplate = useCallback(() => {
const template: AiPromptTemplate = {
id: crypto.randomUUID(),
title: "New Prompt",
prompt: ""
};
setAiPromptTemplates((current) => [...current, template]);
setExpandedPromptId(template.id);
}, []);
const handleDeleteAiPromptTemplate = useCallback((templateId: string) => {
setAiPromptTemplates((current) =>
current.filter((template) => template.id !== templateId)
);
setExpandedPromptId((current) => (current === templateId ? null : current));
}, []);
const getCurrentAiSelection = useCallback((): AiSelection | null => {
const view = editorViewRef.current;
if (!view) return null;
const main = view.state.selection.main;
if (main.empty) return null;
const from = Math.min(main.from, main.to);
const to = Math.max(main.from, main.to);
if (from === to) return null;
return {
from,
to,
text: view.state.doc.sliceString(from, to)
};
}, []);
const dispatchAiAction = useCallback(
(template: AiPromptTemplate | null, isCustomPrompt: boolean) => {
const selection = getCurrentAiSelection();
if (selection) {
setPendingAiScopeChoice({
template,
selection,
isCustomPrompt
});
return;
}
if (isCustomPrompt) {
setCustomPromptText("");
setCustomPromptState({
scope: "document",
selection: null
});
return;
}
if (!template) return;
runAiAction({ template }).catch((error) => {
console.error("Failed to run AI tool", error);
});
},
[getCurrentAiSelection, runAiAction]
);
const handleChooseAiScope = useCallback(
(scope: AiEditScope) => {
const pending = pendingAiScopeChoice;
if (!pending) return;
setPendingAiScopeChoice(null);
if (pending.isCustomPrompt) {
setCustomPromptText("");
setCustomPromptState({
scope,
selection: scope === "selection" ? pending.selection : null
});
return;
}
if (!pending.template) return;
runAiAction({
template: pending.template,
scope,
selection: scope === "selection" ? pending.selection : null
}).catch((error) => {
console.error("Failed to run AI tool", error);
});
},
[pendingAiScopeChoice, runAiAction]
);
const statusKey = useMemo(() => {
if (isViewingHistory) return "history";
if (isDirty) return "unsaved";
if (hasDraft) return "draft";
return "saved";
}, [hasDraft, isDirty, isViewingHistory]);
const canSave = !isViewingHistory && (isDirty || hasDraft);
const statusLabel = useMemo(() => {
switch (statusKey) {
case "history":
return "Viewing history";
case "unsaved":
return "Unsaved";
case "draft":
return "Draft autosaved";
default:
return "Saved";
}
}, [statusKey]);
const conversionLabel = useMemo(() => {
if (!conversionJob) return null;
if (conversionJob.sourceTextId === selectedTextId) {
return conversionJob.actionLabel;
}
return `${conversionJob.actionLabel}: ${conversionJob.sourceTitle}`;
}, [conversionJob, selectedTextId]);
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
const folderIconSrc = theme === "light" ? folderIconBright : folderIcon;
const newFolderIconSrc = theme === "light" ? newFolderIconBright : newFolderIcon;
const newTextIconSrc = theme === "light" ? newTextIconBright : newTextIcon;
const openFileIconSrc = theme === "light" ? openFileIconBright : openFileIcon;
const settingsIconSrc = theme === "light" ? settingsIconBright : settingsIcon;
const sidebarCollapseIconSrc =
theme === "light" ? sidebarCollapseIconBright : sidebarCollapseIcon;
const sidebarExpandIconSrc =
theme === "light" ? sidebarExpandIconBright : sidebarExpandIcon;
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
const extensions = showLineNumbersActive
? [lineNumbers(), highlightActiveLineGutter()]
: [];
view.dispatch({
effects: lineNumbersCompartmentRef.current.reconfigure(extensions)
});
}, [editorReady, showLineNumbersActive]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: editableCompartmentRef.current.reconfigure(
EditorView.editable.of(!isViewingHistory && (!markdownPreview || splitView))
)
});
}, [editorReady, isViewingHistory, markdownPreview, splitView]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(editorThemeExtension)
});
}, [editorReady, editorThemeExtension]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
if (body === editorValueRef.current) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: body },
annotations: Transaction.addToHistory.of(false)
});
editorValueRef.current = body;
}, [body]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.requestMeasure();
}, [markdownPreview, splitView, textSize, sidebarCollapsed, historyOpen]);
useEffect(() => {
if (!pendingEditorFocusRef.current) return;
if (!selectedTextId || !editorReady || isViewingHistory) return;
if (markdownPreview && !splitView) return;
pendingEditorFocusRef.current = false;
window.requestAnimationFrame(() => {
editorViewRef.current?.focus();
});
}, [editorReady, isViewingHistory, markdownPreview, selectedTextId, splitView]);
const refreshTexts = useCallback(async () => {
setLoadingTexts(true);
try {
const trimmed = textSearch.trim();
const rows = trimmed ? await searchTexts(trimmed) : await listTexts();
setTexts(rows);
} finally {
setLoadingTexts(false);
}
}, [textSearch]);
const refreshFolders = useCallback(async () => {
setLoadingFolders(true);
try {
const rows = await listFolders();
setFolders(rows);
} finally {
setLoadingFolders(false);
}
}, []);
const isFolderExpanded = useCallback(
(folderId: string) => {
if (hasSearch) {
return visibleFolderIds?.has(folderId) ?? false;
}
return expandedFolders.has(folderId);
},
[expandedFolders, hasSearch, visibleFolderIds]
);
const toggleFolderExpanded = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
const refreshVersions = useCallback(async (targetTextId?: string | null) => {
const textId = targetTextId ?? selectedTextIdRef.current;
if (!textId || !historyOpenRef.current) return;
const [manualRows, draft] = await Promise.all([
listVersions(textId),
getDraft(textId)
]);
const manualItems: HistoryEntry[] = manualRows.map((row) => ({
id: row.id,
created_at: row.created_at,
kind: "manual",
body: row.body
}));
const draftItem: HistoryEntry[] = draft
? [
{
id: `draft:${textId}`,
created_at: draft.updated_at,
kind: "draft",
body: draft.body,
baseVersionId: draft.base_version_id ?? null
}
]
: [];
const combined = [...draftItem, ...manualItems].sort(
(a, b) => b.created_at - a.created_at
);
setHistoryItems(combined);
}, []);
const persistCurrentTextVersion = useCallback(
async (promptId: string, nextTitle: string, nextBody: string) => {
const normalizedTitle = nextTitle.trim() || DEFAULT_TITLE;
if (
selectedTextIdRef.current === promptId &&
normalizedTitle !== nextTitle
) {
setTitle(normalizedTitle);
}
const result = await saveManualVersion(promptId, normalizedTitle, nextBody);
if (selectedTextIdRef.current === promptId) {
setLastPersistedBody(nextBody);
setLastPersistedTitle(normalizedTitle);
setHasDraft(false);
setRestoredDraft(false);
setLatestManualVersionId(result.versionId);
setDraftBaseVersionId(result.versionId);
setSelectedHistoryId(result.versionId);
}
await refreshTexts();
if (historyOpenRef.current && selectedTextIdRef.current === promptId) {
await refreshVersions(promptId);
}
return {
versionId: result.versionId,
normalizedTitle
};
},
[refreshTexts, refreshVersions]
);
const runAiAction = useCallback(
async ({ template, scope = "document", selection = null }: AiActionRequest) => {
if (!selectedTextId || !hasText || isViewingHistory || isConverting) return;
if (!ollamaModel) {
setConfirmState({
title: "Ollama",
message: "Select an Ollama model first.",
actionLabel: "OK",
onConfirm: () => {}
});
return;
}
const resolvedScope = scope === "selection" && selection ? "selection" : "document";
const actionLabel =
resolvedScope === "selection"
? `${getAiPromptTemplateLabel(template)} (Selection)`
: getAiPromptTemplateLabel(template);
const prompt = template.prompt.trim();
if (!prompt) {
setConfirmState({
title: "Prompt template",
message: `The prompt template "${actionLabel}" is empty.`,
actionLabel: "OK",
onConfirm: () => {}
});
return;
}
const controller = new AbortController();
const sourceTextId = selectedTextId;
const sourceBody = body;
let sourceTitle = title.trim() || DEFAULT_TITLE;
let sourceDraftBaseVersionId = draftBaseVersionId;
if (canSave) {
const persisted = await persistCurrentTextVersion(
sourceTextId,
title,
sourceBody
);
sourceTitle = persisted.normalizedTitle;
sourceDraftBaseVersionId = persisted.versionId;
}
const sourceInput =
resolvedScope === "selection" && selection ? selection.text : sourceBody;
const sourceLabel = resolvedScope === "selection" ? "Selected text" : "Document";
const fullPrompt = `${prompt}\n\n${sourceLabel}:\n${sourceInput}`;
setConversionJob({
actionLabel,
openPreviewOnSuccess: Boolean(template.openPreviewOnSuccess),
sourceTextId,
sourceTitle,
sourceBody,
sourceDraftBaseVersionId,
controller
});
try {
const response = await fetch(`${normalizedOllamaUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model: ollamaModel,
prompt: fullPrompt,
stream: false
})
});
if (!response.ok) {
throw new Error(`Ollama responded with ${response.status}`);
}
const data = await response.json();
const resultText = typeof data?.response === "string" ? data.response.trim() : "";
if (!resultText) {
throw new Error("Ollama returned an empty response.");
}
if (controller.signal.aborted) {
return;
}
const nextBody =
resolvedScope === "selection" && selection
? `${sourceBody.slice(0, selection.from)}${resultText}${sourceBody.slice(selection.to)}`
: resultText;
const hasLiveEditsOnSource =
selectedTextIdRef.current === sourceTextId &&
viewingVersionRef.current === null &&
bodyRef.current !== sourceBody;
if (hasLiveEditsOnSource) {
setConfirmState({
title: "AI edit skipped",
message: `${actionLabel} finished, but the source text changed while it was running. The result was not applied.`,
actionLabel: "OK",
onConfirm: () => {}
});
return;
}
await upsertDraft(sourceTextId, nextBody, sourceDraftBaseVersionId);
const canApplyToVisibleEditor =
selectedTextIdRef.current === sourceTextId &&
viewingVersionRef.current === null;
if (canApplyToVisibleEditor) {
setBody(nextBody);
setLastPersistedBody(nextBody);
setHasDraft(true);
setRestoredDraft(false);
setDraftBaseVersionId(sourceDraftBaseVersionId);
setSelectedHistoryId(`draft:${sourceTextId}`);
setViewingVersion(null);
historySnapshotRef.current = null;
if (template.openPreviewOnSuccess) {
setMarkdownPreview(true);
}
}
await refreshTexts();
if (selectedTextIdRef.current === sourceTextId && historyOpenRef.current) {
await refreshVersions(sourceTextId);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Failed to run AI edit", error);
setConfirmState({
title: "Ollama error",
message: error instanceof Error ? error.message : `${actionLabel} failed.`,
actionLabel: "OK",
onConfirm: () => {}
});
} finally {
setConversionJob((current) =>
current?.controller === controller ? null : current
);
}
},
[
body,
canSave,
draftBaseVersionId,
hasText,
isConverting,
isViewingHistory,
normalizedOllamaUrl,
ollamaModel,
persistCurrentTextVersion,
refreshTexts,
refreshVersions,
selectedTextId,
title
]
);
const handleRunCustomPrompt = useCallback(async () => {
const prompt = customPromptText.trim();
const request = customPromptState;
if (!prompt || !request) return;
setCustomPromptState(null);
setCustomPromptText("");
await runAiAction({
template: {
id: "custom-prompt",
title: "Custom Prompt",
prompt
},
scope: request.scope,
selection: request.selection
});
}, [customPromptState, customPromptText, runAiAction]);
const handleOpenAiToolsMenu = useCallback(async () => {
if (!selectedTextId || !hasText || isViewingHistory || isConverting) return;
const menu = await Menu.new({
items: [
{
text: "Custom prompt...",
action: () => {
dispatchAiAction(null, true);
}
},
...aiPromptTemplates.map((template) => ({
text: getAiPromptTemplateLabel(template),
action: () => {
dispatchAiAction(template, false);
}
}))
]
});
await menu.popup(undefined, getCurrentWindow());
}, [
aiPromptTemplates,
dispatchAiAction,
hasText,
isConverting,
isViewingHistory,
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, selectedTextId]);
useEffect(() => {
if (!selectedTextId) {
const view = editorViewRef.current;
if (view) {
closeSearchPanel(view);
}
searchRestoreSplitRef.current = null;
setTitle("");
setLastPersistedTitle("");
setBody("");
setLastPersistedBody("");
setHasDraft(false);
setRestoredDraft(false);
setLatestManualVersionId(null);
setDraftBaseVersionId(null);
setViewingVersion(null);
setSelectedHistoryId(null);
historySnapshotRef.current = null;
return;
}
let cancelled = false;
const loadText = async () => {
const [text, manualVersion, draft] = await Promise.all([
getText(selectedTextId),
getLatestManualVersion(selectedTextId),
getDraft(selectedTextId)
]);
if (cancelled) return;
if (!text) {
setSelectedTextId(null);
localStorage.removeItem("textdb.selectedTextId");
return;
}
const resolvedTitle = text.title || DEFAULT_TITLE;
const resolvedBody = draft?.body ?? manualVersion?.body ?? "";
const baseVersionId = draft?.base_version_id ?? manualVersion?.id ?? null;
setTitle(resolvedTitle);
setLastPersistedTitle(resolvedTitle);
setBody(resolvedBody);
setLastPersistedBody(resolvedBody);
setHasDraft(Boolean(draft));
setRestoredDraft(Boolean(draft));
setLatestManualVersionId(manualVersion?.id ?? null);
setDraftBaseVersionId(baseVersionId);
setViewingVersion(null);
setSelectedHistoryId(
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
);
historySnapshotRef.current = null;
};
loadText().catch((error) => {
console.error("Failed to load text", error);
});
return () => {
cancelled = true;
};
}, [selectedTextId]);
useEffect(() => {
if (!selectedTextId || !isDirty || isViewingHistory) return;
const currentBody = body;
const handle = window.setTimeout(() => {
upsertDraft(selectedTextId, currentBody, draftBaseVersionId)
.then(() => {
if (bodyRef.current !== currentBody) return;
setHasDraft(true);
setLastPersistedBody(currentBody);
setSelectedHistoryId(`draft:${selectedTextId}`);
if (historyOpen) {
refreshVersions().catch((error) => {
console.error("Failed to refresh history", error);
});
}
})
.catch((error) => {
console.error("Failed to autosave draft", error);
});
}, 600);
return () => window.clearTimeout(handle);
}, [
body,
draftBaseVersionId,
historyOpen,
isDirty,
isViewingHistory,
refreshVersions,
selectedTextId
]);
const handleNewText = useCallback(async () => {
const { textId } = await createText(DEFAULT_TITLE, "", null);
await refreshTexts();
pendingEditorFocusRef.current = true;
setMarkdownPreview(false);
setSelectedTextId(textId);
}, [refreshTexts]);
const handleNewFolder = useCallback(async () => {
const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null);
await refreshFolders();
setEditingTextId(null);
setEditingTextTitle("");
setEditingFolderId(folderId);
setEditingFolderName(DEFAULT_FOLDER_NAME);
}, [refreshFolders]);
const clearFolderEditing = useCallback(() => {
setEditingFolderId(null);
setEditingFolderName("");
}, []);
const clearTextEditing = useCallback(() => {
setEditingTextId(null);
setEditingTextTitle("");
}, []);
const startEditingFolder = useCallback((folder: Folder) => {
setEditingFolderId(folder.id);
setEditingFolderName(folder.name);
setEditingTextId(null);
setEditingTextTitle("");
}, []);
const startEditingText = useCallback((text: Text) => {
setEditingTextId(text.id);
setEditingTextTitle(text.title);
setEditingFolderId(null);
setEditingFolderName("");
}, []);
const commitFolderEdit = useCallback(async () => {
if (!editingFolderId) return;
const folderId = editingFolderId;
const nextName = editingFolderName.trim() || DEFAULT_FOLDER_NAME;
const currentName = folderById.get(folderId)?.name ?? "";
clearFolderEditing();
if (nextName === currentName) return;
await updateFolderName(folderId, nextName);
await refreshFolders();
}, [
clearFolderEditing,
editingFolderId,
editingFolderName,
folderById,
refreshFolders
]);
const commitTextEdit = useCallback(async () => {
if (!editingTextId) return;
const textId = editingTextId;
const nextTitle = editingTextTitle.trim() || DEFAULT_TITLE;
const currentTitle = texts.find((text) => text.id === textId)?.title ?? "";
clearTextEditing();
if (nextTitle === currentTitle) return;
if (selectedTextId === textId) {
setTitle(nextTitle);
setLastPersistedTitle(nextTitle);
}
await updateTextTitle(textId, nextTitle);
await refreshTexts();
}, [
clearTextEditing,
editingTextId,
editingTextTitle,
refreshTexts,
selectedTextId,
texts
]);
const buildFolderPath = useCallback(
(folderId: string) => {
const names: string[] = [];
let current: string | null = folderId;
const seen = new Set<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
]);
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<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;
if ((customPromptState || pendingAiScopeChoice) && event.key === "Escape") {
event.preventDefault();
setCustomPromptState(null);
setPendingAiScopeChoice(null);
return;
}
if (customPromptState || pendingAiScopeChoice) {
return;
}
const isFind =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f";
if (isFind && !settingsOpen && !confirmState) {
const opened = openDocumentSearch();
if (opened) {
event.preventDefault();
return;
}
}
const isSave =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
if (isSave) {
event.preventDefault();
handleSaveVersion().catch((error) => {
console.error("Failed to save version", error);
});
return;
}
if (event.key === "Tab") return;
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
confirmState,
customPromptState,
handleSaveVersion,
openDocumentSearch,
pendingAiScopeChoice,
settingsOpen
]);
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)}
>
<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>
)}
<button
className="folder-item__delete"
onClick={(event) => {
event.stopPropagation();
requestDeleteFolder(folder).catch((error) => {
console.error("Failed to delete folder", error);
});
}}
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>
);
};
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"
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}
<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" : ""
}`}
>
<section className="editor">
<div className="editor__header">
<div className="editor__title-row">
<input
className="title-input"
value={title}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Text title"
disabled={isViewingHistory}
/>
<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" />
</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>
<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 : handleOpenAiToolsMenu}
disabled={
isConverting
? false
: !ollamaModel || isViewingHistory || !hasText
}
>
{isConverting ? "Cancel AI Edit" : "AI Tools"}
</button>
{hasText ? (
<button className="button" onClick={handleExportText}>
Export Text
</button>
) : null}
{hasText && markdownPreview ? (
<button className="button" type="button" onClick={handlePrintMarkdown}>
Print
</button>
) : null}
</>
) : 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}
<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>
</div>
<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>
</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"}
</option>
)}
</select>
{ollamaError ? (
<div className="settings-panel__hint">{ollamaError}</div>
) : null}
</div>
<div className="settings-panel__section-title">Prompts</div>
<div className="settings-panel__section settings-panel__section--flush">
<button
className="button"
type="button"
onClick={handleAddAiPromptTemplate}
>
Add Prompt Template
</button>
{aiPromptTemplates.map((template) => {
const isOpen = expandedPromptId === template.id;
return (
<div
key={template.id}
className={`prompt-accordion${isOpen ? " is-open" : ""}`}
>
<button
className="prompt-accordion__toggle"
type="button"
onClick={() =>
setExpandedPromptId((current) =>
current === template.id ? null : template.id
)
}
>
<span>{getAiPromptTemplateLabel(template)}</span>
<span className="prompt-accordion__indicator" aria-hidden="true">
{isOpen ? "" : "+"}
</span>
</button>
{isOpen ? (
<div className="prompt-accordion__body">
<label className="settings-panel__label">
Title
</label>
<input
className="settings-panel__input"
type="text"
value={template.title}
onChange={(event) =>
handleUpdateAiPromptTemplate(
template.id,
"title",
event.target.value
)
}
placeholder="Prompt title"
/>
<label className="settings-panel__label">
Prompt
</label>
<textarea
className="settings-panel__textarea"
value={template.prompt}
onChange={(event) =>
handleUpdateAiPromptTemplate(
template.id,
"prompt",
event.target.value
)
}
rows={7}
/>
<div className="prompt-accordion__actions">
<button
className="button button--danger"
type="button"
onClick={() => handleDeleteAiPromptTemplate(template.id)}
>
Remove Template
</button>
</div>
</div>
) : null}
</div>
);
})}
</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>
</div>
</div>
</div>
) : null}
{pendingAiScopeChoice ? (
<div className="modal">
<div className="modal__overlay" onClick={() => setPendingAiScopeChoice(null)} />
<div className="modal__card" role="dialog" aria-modal="true">
<div className="modal__title">AI Edit Scope</div>
<div className="modal__message">
A text selection is active. Do you want to edit the whole text or just the selected part?
</div>
<div className="modal__selection-preview">
{pendingAiScopeChoice.selection.text}
</div>
<div className="modal__actions">
<button className="button" onClick={() => setPendingAiScopeChoice(null)}>
Cancel
</button>
<button className="button" onClick={() => handleChooseAiScope("document")}>
Whole Text
</button>
<button
className="button button--primary"
onClick={() => handleChooseAiScope("selection")}
>
Selected Part
</button>
</div>
</div>
</div>
) : null}
{customPromptState ? (
<div className="modal">
<div className="modal__overlay" onClick={() => setCustomPromptState(null)} />
<div className="modal__card modal__card--wide" role="dialog" aria-modal="true">
<div className="modal__title">Custom Prompt</div>
<div className="modal__message">
Tell the AI what it should do with the current{" "}
{customPromptState.scope === "selection" ? "selection" : "text"}.
</div>
<textarea
className="modal__textarea"
value={customPromptText}
onChange={(event) => setCustomPromptText(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "enter") {
event.preventDefault();
handleRunCustomPrompt().catch((error) => {
console.error("Failed to run custom prompt", error);
});
} else if (event.key === "Escape") {
event.preventDefault();
setCustomPromptState(null);
}
}}
placeholder="Example: Turn this into a short release note with bullet points."
autoFocus
/>
<div className="modal__hint">Press Cmd/Ctrl+Enter to run.</div>
<div className="modal__actions">
<button className="button" onClick={() => setCustomPromptState(null)}>
Cancel
</button>
<button
className="button button--primary"
onClick={() => {
handleRunCustomPrompt().catch((error) => {
console.error("Failed to run custom prompt", error);
});
}}
disabled={customPromptText.trim().length === 0}
>
Run Prompt
</button>
</div>
</div>
</div>
) : null}
{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>
);
}