2026-02-01 03:50:28 +01:00
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-01-31 00:37:25 +01:00
|
|
|
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
2026-01-31 14:31:09 +01:00
|
|
|
|
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
2026-01-31 14:35:53 +01:00
|
|
|
|
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
2026-03-13 21:56:37 +01:00
|
|
|
|
import { copyFile, exists, readTextFile, remove, writeTextFile } from "@tauri-apps/plugin-fs";
|
2026-01-31 18:44:57 +01:00
|
|
|
|
import { Menu } from "@tauri-apps/api/menu";
|
2026-01-31 00:37:25 +01:00
|
|
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
2026-03-13 21:56:37 +01:00
|
|
|
|
import { appDataDir, join } from "@tauri-apps/api/path";
|
2026-01-31 00:41:12 +01:00
|
|
|
|
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";
|
2026-03-11 11:04:04 +01:00
|
|
|
|
import {
|
|
|
|
|
|
closeSearchPanel,
|
|
|
|
|
|
openSearchPanel,
|
|
|
|
|
|
search,
|
|
|
|
|
|
searchKeymap,
|
|
|
|
|
|
searchPanelOpen
|
|
|
|
|
|
} from "@codemirror/search";
|
2026-02-01 03:59:29 +01:00
|
|
|
|
import {
|
|
|
|
|
|
EditorView,
|
|
|
|
|
|
keymap,
|
|
|
|
|
|
lineNumbers,
|
|
|
|
|
|
highlightActiveLineGutter
|
|
|
|
|
|
} from "@codemirror/view";
|
2026-02-01 04:18:19 +01:00
|
|
|
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
2026-02-01 01:58:44 +01:00
|
|
|
|
import folderIcon from "../src-tauri/icons/folder.png";
|
|
|
|
|
|
import folderIconBright from "../src-tauri/icons/folder_b.png";
|
2026-02-01 03:13:17 +01:00
|
|
|
|
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";
|
2026-01-31 14:19:42 +01:00
|
|
|
|
import { markdownToHTML } from "./markdown/markdown";
|
|
|
|
|
|
import "./markdown/markdown-render.css";
|
2026-01-30 23:14:06 +01:00
|
|
|
|
import {
|
2026-01-31 18:44:50 +01:00
|
|
|
|
createFolder,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
createText,
|
2026-01-31 19:09:56 +01:00
|
|
|
|
deleteFolder,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
deleteText,
|
|
|
|
|
|
deleteTextVersion,
|
|
|
|
|
|
discardDraft,
|
2026-03-13 21:56:37 +01:00
|
|
|
|
exportDatabaseSnapshot,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
getDraft,
|
|
|
|
|
|
getLatestManualVersion,
|
|
|
|
|
|
getText,
|
2026-01-31 18:44:50 +01:00
|
|
|
|
listFolders,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
listTexts,
|
|
|
|
|
|
listVersions,
|
2026-02-01 01:06:42 +01:00
|
|
|
|
moveFolder,
|
2026-01-31 18:44:50 +01:00
|
|
|
|
moveTextToFolder,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
saveManualVersion,
|
2026-01-31 18:44:50 +01:00
|
|
|
|
searchTexts,
|
2026-01-31 19:09:56 +01:00
|
|
|
|
updateFolderName,
|
2026-01-30 23:14:06 +01:00
|
|
|
|
updateTextTitle,
|
|
|
|
|
|
upsertDraft,
|
2026-01-31 18:44:50 +01:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 21:56:37 +01:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 21:56:37 +01:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-11 11:04:04 +01:00
|
|
|
|
type ConversionJob = {
|
|
|
|
|
|
sourceTextId: string;
|
|
|
|
|
|
sourceTitle: string;
|
|
|
|
|
|
sourceBody: string;
|
|
|
|
|
|
controller: AbortController;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type DocumentStats = {
|
|
|
|
|
|
characters: number;
|
|
|
|
|
|
words: number;
|
|
|
|
|
|
sentences: number;
|
|
|
|
|
|
estimatedTokens: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-01 01:55:47 +01:00
|
|
|
|
type SidebarEntry =
|
|
|
|
|
|
| { kind: "folder"; item: Folder }
|
|
|
|
|
|
| { kind: "text"; item: Text };
|
|
|
|
|
|
|
2026-01-30 23:14:06 +01:00
|
|
|
|
const DEFAULT_TITLE = "Untitled Text";
|
2026-01-31 19:09:56 +01:00
|
|
|
|
const DEFAULT_FOLDER_NAME = "New Folder";
|
2026-02-01 05:09:18 +01:00
|
|
|
|
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
|
|
|
|
const DEFAULT_OLLAMA_PROMPT = `Convert the following plain text into well-formatted Markdown.
|
|
|
|
|
|
Do not change or omit any content.
|
|
|
|
|
|
Only add Markdown structure (such as headings, lists, code blocks, tables, quotes, links, bold, italics, etc.) where appropriate, based on the meaning and structure of the original text.
|
|
|
|
|
|
Keep the content itself unaltered and do not translate, summarize or rephrase. Only use your Markdown-formatting skills.
|
|
|
|
|
|
Text:`;
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
2026-03-11 11:04:04 +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);
|
2026-01-31 18:45:05 +01:00
|
|
|
|
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);
|
2026-01-31 12:39:25 +01:00
|
|
|
|
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);
|
2026-01-31 14:19:42 +01:00
|
|
|
|
const [markdownPreview, setMarkdownPreview] = useState(false);
|
2026-01-31 19:04:20 +01:00
|
|
|
|
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
|
|
|
|
|
|
const [editingFolderName, setEditingFolderName] = useState("");
|
|
|
|
|
|
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
|
|
|
|
|
const [editingTextTitle, setEditingTextTitle] = useState("");
|
2026-01-31 18:45:05 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-02-01 04:34:58 +01:00
|
|
|
|
const [theme, setTheme] = useState<"dark" | "light">(() => {
|
2026-01-31 12:47:55 +01:00
|
|
|
|
const storedTheme = localStorage.getItem("textdb.theme");
|
2026-02-01 04:34:58 +01:00
|
|
|
|
if (storedTheme === "dark" || storedTheme === "light") {
|
|
|
|
|
|
return storedTheme;
|
|
|
|
|
|
}
|
|
|
|
|
|
return storedTheme === "default" ? "dark" : "dark";
|
2026-01-31 12:47:55 +01:00
|
|
|
|
});
|
|
|
|
|
|
const [textSize, setTextSize] = useState(() => {
|
|
|
|
|
|
const storedSize = Number(localStorage.getItem("textdb.textSize"));
|
|
|
|
|
|
if (!Number.isNaN(storedSize) && storedSize >= 12 && storedSize <= 18) {
|
|
|
|
|
|
return storedSize;
|
|
|
|
|
|
}
|
|
|
|
|
|
return 16;
|
|
|
|
|
|
});
|
2026-01-31 12:58:29 +01:00
|
|
|
|
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
2026-02-01 04:33:39 +01:00
|
|
|
|
const stored = localStorage.getItem("textdb.lineNumbers");
|
|
|
|
|
|
return stored === null ? true : stored === "true";
|
2026-01-31 12:58:29 +01:00
|
|
|
|
});
|
2026-02-01 04:29:19 +01:00
|
|
|
|
const [splitView, setSplitView] = useState(() => {
|
2026-02-01 04:33:39 +01:00
|
|
|
|
const stored = localStorage.getItem("textdb.splitView");
|
|
|
|
|
|
return stored === null ? true : stored === "true";
|
2026-02-01 04:29:19 +01:00
|
|
|
|
});
|
2026-02-01 05:09:18 +01:00
|
|
|
|
const [ollamaUrl, setOllamaUrl] = useState(() => {
|
|
|
|
|
|
return localStorage.getItem("textdb.ollamaUrl") || DEFAULT_OLLAMA_URL;
|
|
|
|
|
|
});
|
|
|
|
|
|
const [ollamaModel, setOllamaModel] = useState(() => {
|
|
|
|
|
|
return localStorage.getItem("textdb.ollamaModel") || "";
|
|
|
|
|
|
});
|
|
|
|
|
|
const [ollamaPrompt, setOllamaPrompt] = useState(() => {
|
|
|
|
|
|
return localStorage.getItem("textdb.ollamaPrompt") || DEFAULT_OLLAMA_PROMPT;
|
|
|
|
|
|
});
|
|
|
|
|
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
|
|
|
|
|
const [ollamaLoading, setOllamaLoading] = useState(false);
|
|
|
|
|
|
const [ollamaError, setOllamaError] = useState<string | null>(null);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const [conversionJob, setConversionJob] = useState<ConversionJob | null>(null);
|
2026-01-31 12:51:04 +01:00
|
|
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
|
|
|
|
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
|
|
|
|
|
|
});
|
2026-02-01 04:10:13 +01:00
|
|
|
|
const [editorReady, setEditorReady] = useState(false);
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
|
|
|
|
|
const bodyRef = useRef(body);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const selectedTextIdRef = useRef<string | null>(selectedTextId);
|
|
|
|
|
|
const historyOpenRef = useRef(historyOpen);
|
|
|
|
|
|
const viewingVersionRef = useRef<HistoryEntry | null>(viewingVersion);
|
2026-02-01 03:59:44 +01:00
|
|
|
|
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);
|
2026-01-31 01:04:56 +01:00
|
|
|
|
const recentOpenRef = useRef(new Map<string, number>());
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const searchRestoreSplitRef = useRef<boolean | null>(null);
|
2026-03-13 21:53:57 +01:00
|
|
|
|
const pendingEditorFocusRef = useRef(false);
|
2026-01-31 19:09:56 +01:00
|
|
|
|
const ignoreTextBlurRef = useRef(false);
|
|
|
|
|
|
const ignoreFolderBlurRef = useRef(false);
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
2026-01-31 00:37:32 +01:00
|
|
|
|
|
2026-02-01 04:09:15 +01:00
|
|
|
|
bodyRef.current = body;
|
2026-03-11 11:04:04 +01:00
|
|
|
|
selectedTextIdRef.current = selectedTextId;
|
|
|
|
|
|
historyOpenRef.current = historyOpen;
|
|
|
|
|
|
viewingVersionRef.current = viewingVersion;
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
2026-01-31 12:45:26 +01:00
|
|
|
|
|
2026-01-31 12:39:25 +01:00
|
|
|
|
useEffect(() => {
|
2026-02-01 04:35:11 +01:00
|
|
|
|
document.body.dataset.theme = theme === "dark" ? "default" : "light";
|
2026-01-31 12:45:26 +01:00
|
|
|
|
localStorage.setItem("textdb.theme", theme);
|
2026-01-31 12:39:25 +01:00
|
|
|
|
}, [theme]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
document.documentElement.style.setProperty("--base-font-size", `${textSize}px`);
|
2026-01-31 12:45:26 +01:00
|
|
|
|
localStorage.setItem("textdb.textSize", String(textSize));
|
2026-01-31 12:39:25 +01:00
|
|
|
|
}, [textSize]);
|
|
|
|
|
|
|
2026-01-31 12:51:04 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed));
|
|
|
|
|
|
}, [sidebarCollapsed]);
|
|
|
|
|
|
|
2026-01-31 13:00:56 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.lineNumbers", String(showLineNumbers));
|
|
|
|
|
|
}, [showLineNumbers]);
|
|
|
|
|
|
|
2026-02-01 04:29:19 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.splitView", String(splitView));
|
|
|
|
|
|
}, [splitView]);
|
|
|
|
|
|
|
2026-02-01 05:09:18 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.ollamaUrl", ollamaUrl);
|
|
|
|
|
|
}, [ollamaUrl]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.ollamaModel", ollamaModel);
|
|
|
|
|
|
}, [ollamaModel]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem("textdb.ollamaPrompt", ollamaPrompt);
|
|
|
|
|
|
}, [ollamaPrompt]);
|
|
|
|
|
|
|
2026-01-31 18:45:16 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
|
"textdb.expandedFolders",
|
|
|
|
|
|
JSON.stringify(Array.from(expandedFolders))
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [expandedFolders]);
|
|
|
|
|
|
|
2026-01-31 12:51:04 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedTextId) {
|
|
|
|
|
|
localStorage.setItem("textdb.selectedTextId", selectedTextId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedTextId]);
|
|
|
|
|
|
|
2026-03-12 16:00:47 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 04:09:15 +01:00
|
|
|
|
const editorHostRef = useCallback((node: HTMLDivElement | null) => {
|
|
|
|
|
|
if (!node) {
|
|
|
|
|
|
if (editorViewRef.current) {
|
|
|
|
|
|
editorViewRef.current.destroy();
|
|
|
|
|
|
editorViewRef.current = null;
|
2026-02-01 04:10:13 +01:00
|
|
|
|
setEditorReady(false);
|
2026-02-01 04:09:15 +01:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (editorViewRef.current) return;
|
2026-02-01 03:59:49 +01:00
|
|
|
|
const state = EditorState.create({
|
2026-02-01 04:09:15 +01:00
|
|
|
|
doc: bodyRef.current,
|
2026-02-01 03:59:49 +01:00
|
|
|
|
extensions: [
|
|
|
|
|
|
EditorView.lineWrapping,
|
|
|
|
|
|
history(),
|
2026-03-11 11:04:04 +01:00
|
|
|
|
search(),
|
|
|
|
|
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
2026-03-12 15:59:25 +01:00
|
|
|
|
themeCompartmentRef.current.of(editorThemeExtension),
|
2026-02-01 03:59:49 +01:00
|
|
|
|
lineNumbersCompartmentRef.current.of([]),
|
|
|
|
|
|
editableCompartmentRef.current.of(EditorView.editable.of(true)),
|
|
|
|
|
|
EditorView.updateListener.of((update) => {
|
2026-03-11 11:04:04 +01:00
|
|
|
|
if (!searchPanelOpen(update.state) && searchRestoreSplitRef.current !== null) {
|
|
|
|
|
|
const nextSplitView = searchRestoreSplitRef.current;
|
|
|
|
|
|
searchRestoreSplitRef.current = null;
|
|
|
|
|
|
setSplitView(nextSplitView);
|
|
|
|
|
|
}
|
2026-02-01 03:59:49 +01:00
|
|
|
|
if (!update.docChanged) return;
|
|
|
|
|
|
const value = update.state.doc.toString();
|
|
|
|
|
|
editorValueRef.current = value;
|
|
|
|
|
|
setBody(value);
|
|
|
|
|
|
})
|
|
|
|
|
|
]
|
|
|
|
|
|
});
|
|
|
|
|
|
const view = new EditorView({
|
|
|
|
|
|
state,
|
2026-02-01 04:09:15 +01:00
|
|
|
|
parent: node
|
2026-02-01 03:59:49 +01:00
|
|
|
|
});
|
|
|
|
|
|
editorViewRef.current = view;
|
2026-02-01 04:09:15 +01:00
|
|
|
|
editorValueRef.current = bodyRef.current;
|
2026-02-01 04:10:13 +01:00
|
|
|
|
setEditorReady(true);
|
2026-03-12 15:59:25 +01:00
|
|
|
|
}, [editorThemeExtension]);
|
2026-02-01 03:59:49 +01:00
|
|
|
|
|
2026-01-30 23:14:06 +01:00
|
|
|
|
const isViewingHistory = viewingVersion !== null;
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const isConverting = conversionJob !== null;
|
2026-01-30 23:14:06 +01:00
|
|
|
|
const isDirty = !isViewingHistory && body !== lastPersistedBody;
|
|
|
|
|
|
const hasText = body.trim().length > 0;
|
2026-02-01 04:29:19 +01:00
|
|
|
|
const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView);
|
2026-03-11 11:07:17 +01:00
|
|
|
|
const hasSearch = textSearch.trim().length > 0;
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const documentStats = useMemo(() => getDocumentStats(body), [body]);
|
2026-02-01 03:51:53 +01:00
|
|
|
|
const markdownHtml = useMemo(
|
|
|
|
|
|
() => (markdownPreview ? markdownToHTML(body) : ""),
|
|
|
|
|
|
[body, markdownPreview]
|
|
|
|
|
|
);
|
2026-02-01 05:10:40 +01:00
|
|
|
|
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
|
|
|
|
|
2026-02-01 01:55:47 +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]);
|
|
|
|
|
|
|
2026-02-01 04:39:22 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 14:35:53 +01:00
|
|
|
|
const handleMarkdownPreviewClick = useCallback(
|
2026-01-31 14:31:09 +01:00
|
|
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
|
const target = event.target as HTMLElement | null;
|
2026-01-31 14:35:53 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-31 14:31:09 +01:00
|
|
|
|
const link = target?.closest?.("a");
|
|
|
|
|
|
if (!link) return;
|
|
|
|
|
|
const href = link.getAttribute("href");
|
|
|
|
|
|
if (!href || href.startsWith("#") || href.startsWith("/")) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
openExternal(href);
|
|
|
|
|
|
},
|
|
|
|
|
|
[]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 05:09:41 +01:00
|
|
|
|
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]);
|
2026-02-01 05:09:41 +01:00
|
|
|
|
|
2026-03-13 21:56:51 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!settingsOpen) return;
|
|
|
|
|
|
setDbExportStatus(null);
|
|
|
|
|
|
}, [settingsOpen]);
|
|
|
|
|
|
|
2026-01-31 16:18:32 +01:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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]);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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
|
|
|
|
|
2026-01-31 12:45:26 +01:00
|
|
|
|
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
|
2026-02-01 01:58:44 +01:00
|
|
|
|
const folderIconSrc = theme === "light" ? folderIconBright : folderIcon;
|
2026-02-01 03:13:17 +01:00
|
|
|
|
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;
|
2026-01-31 12:45:26 +01:00
|
|
|
|
|
2026-01-31 13:00:02 +01:00
|
|
|
|
|
2026-01-31 13:04:57 +01:00
|
|
|
|
useEffect(() => {
|
2026-02-01 04:00:16 +01:00
|
|
|
|
const view = editorViewRef.current;
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
const extensions = showLineNumbersActive
|
|
|
|
|
|
? [lineNumbers(), highlightActiveLineGutter()]
|
|
|
|
|
|
: [];
|
|
|
|
|
|
view.dispatch({
|
|
|
|
|
|
effects: lineNumbersCompartmentRef.current.reconfigure(extensions)
|
|
|
|
|
|
});
|
2026-02-01 04:10:13 +01:00
|
|
|
|
}, [editorReady, showLineNumbersActive]);
|
2026-02-01 04:00:16 +01:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const view = editorViewRef.current;
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
view.dispatch({
|
|
|
|
|
|
effects: editableCompartmentRef.current.reconfigure(
|
2026-02-01 04:31:28 +01:00
|
|
|
|
EditorView.editable.of(!isViewingHistory && (!markdownPreview || splitView))
|
2026-02-01 04:00:16 +01:00
|
|
|
|
)
|
|
|
|
|
|
});
|
2026-02-01 04:31:28 +01:00
|
|
|
|
}, [editorReady, isViewingHistory, markdownPreview, splitView]);
|
2026-01-31 13:00:02 +01:00
|
|
|
|
|
2026-03-12 15:59:25 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const view = editorViewRef.current;
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
view.dispatch({
|
|
|
|
|
|
effects: themeCompartmentRef.current.reconfigure(editorThemeExtension)
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [editorReady, editorThemeExtension]);
|
|
|
|
|
|
|
2026-02-01 04:09:15 +01:00
|
|
|
|
|
2026-02-01 03:51:23 +01:00
|
|
|
|
useEffect(() => {
|
2026-02-01 04:00:16 +01:00
|
|
|
|
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]);
|
2026-02-01 03:51:23 +01:00
|
|
|
|
|
2026-02-01 04:01:38 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const view = editorViewRef.current;
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
view.requestMeasure();
|
2026-02-01 04:29:24 +01:00
|
|
|
|
}, [markdownPreview, splitView, textSize, sidebarCollapsed, historyOpen]);
|
2026-02-01 04:01:38 +01:00
|
|
|
|
|
2026-03-13 21:53:57 +01:00
|
|
|
|
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-31 12:45:26 +01:00
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-01-31 18:46:22 +01:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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([
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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-03-11 11:04:04 +01:00
|
|
|
|
}, []);
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
2026-02-01 05:15:20 +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;
|
|
|
|
|
|
}
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const controller = new AbortController();
|
2026-02-01 05:15:20 +01:00
|
|
|
|
const prompt = (ollamaPrompt || DEFAULT_OLLAMA_PROMPT).trim();
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const sourceTextId = selectedTextId;
|
|
|
|
|
|
const sourceBody = body;
|
|
|
|
|
|
const sourceTitle = title.trim() || DEFAULT_TITLE;
|
|
|
|
|
|
const fullPrompt = `${prompt}\n${sourceBody}`;
|
|
|
|
|
|
setConversionJob({
|
|
|
|
|
|
sourceTextId,
|
|
|
|
|
|
sourceTitle,
|
|
|
|
|
|
sourceBody,
|
|
|
|
|
|
controller
|
|
|
|
|
|
});
|
2026-02-01 05:15:20 +01:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${normalizedOllamaUrl}/api/generate`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-03-11 11:04:04 +01:00
|
|
|
|
signal: controller.signal,
|
2026-02-01 05:15:20 +01:00
|
|
|
|
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.");
|
|
|
|
|
|
}
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 05:15:20 +01:00
|
|
|
|
await refreshTexts();
|
2026-03-11 11:04:04 +01:00
|
|
|
|
if (selectedTextIdRef.current === sourceTextId && historyOpenRef.current) {
|
|
|
|
|
|
await refreshVersions(sourceTextId);
|
|
|
|
|
|
}
|
2026-02-01 05:15:20 +01:00
|
|
|
|
} catch (error) {
|
2026-03-11 11:04:04 +01:00
|
|
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-01 05:15:20 +01:00
|
|
|
|
console.error("Failed to convert with Ollama", error);
|
|
|
|
|
|
setConfirmState({
|
|
|
|
|
|
title: "Ollama error",
|
|
|
|
|
|
message: error instanceof Error ? error.message : "Conversion failed.",
|
|
|
|
|
|
actionLabel: "OK",
|
|
|
|
|
|
onConfirm: () => {}
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
2026-03-11 11:04:04 +01:00
|
|
|
|
setConversionJob((current) =>
|
|
|
|
|
|
current?.controller === controller ? null : current
|
|
|
|
|
|
);
|
2026-02-01 05:15:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
}, [
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-31 18:45:45 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshFolders().catch((error) => {
|
|
|
|
|
|
console.error("Failed to load folders", error);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [refreshFolders]);
|
|
|
|
|
|
|
2026-01-30 23:14:06 +01:00
|
|
|
|
useEffect(() => {
|
2026-01-31 12:51:04 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-03-11 11:04:04 +01:00
|
|
|
|
}, [historyOpen, refreshVersions, selectedTextId]);
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedTextId) {
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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);
|
2026-01-31 12:51:04 +01:00
|
|
|
|
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();
|
2026-03-13 21:53:57 +01:00
|
|
|
|
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
|
|
|
|
|
2026-01-31 18:46:40 +01:00
|
|
|
|
const handleNewFolder = useCallback(async () => {
|
2026-02-01 01:50:43 +01:00
|
|
|
|
const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null);
|
2026-01-31 18:46:40 +01:00
|
|
|
|
await refreshFolders();
|
2026-01-31 19:09:56 +01:00
|
|
|
|
setEditingTextId(null);
|
|
|
|
|
|
setEditingTextTitle("");
|
|
|
|
|
|
setEditingFolderId(folderId);
|
|
|
|
|
|
setEditingFolderName(DEFAULT_FOLDER_NAME);
|
2026-02-01 01:50:43 +01:00
|
|
|
|
}, [refreshFolders]);
|
2026-01-31 18:46:40 +01:00
|
|
|
|
|
2026-01-31 19:09:56 +01:00
|
|
|
|
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
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-01-31 18:47:20 +01:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-02-01 01:06:42 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 18:47:20 +01:00
|
|
|
|
const handleMoveTextToFolder = useCallback(
|
|
|
|
|
|
async (textId: string, folderId: string | null) => {
|
2026-02-01 01:50:49 +01:00
|
|
|
|
await moveTextToFolder(textId, folderId);
|
2026-01-31 18:47:20 +01:00
|
|
|
|
await refreshTexts();
|
2026-02-01 02:08:41 +01:00
|
|
|
|
if (folderId) {
|
|
|
|
|
|
setExpandedFolders((prev) => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
next.add(folderId);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-31 18:47:20 +01:00
|
|
|
|
},
|
|
|
|
|
|
[refreshTexts]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 01:06:42 +01:00
|
|
|
|
const handleMoveFolderToFolder = useCallback(
|
|
|
|
|
|
async (folderId: string, parentId: string | null) => {
|
2026-02-01 01:50:49 +01:00
|
|
|
|
await moveFolder(folderId, parentId);
|
2026-02-01 01:06:42 +01:00
|
|
|
|
await refreshFolders();
|
|
|
|
|
|
if (parentId) {
|
|
|
|
|
|
setExpandedFolders((prev) => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
next.add(parentId);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[refreshFolders]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 19:10:24 +01:00
|
|
|
|
const handleDeleteText = useCallback(
|
|
|
|
|
|
async (promptId: string) => {
|
|
|
|
|
|
await deleteText(promptId);
|
|
|
|
|
|
await refreshTexts();
|
|
|
|
|
|
if (selectedTextId === promptId) {
|
|
|
|
|
|
setSelectedTextId(null);
|
|
|
|
|
|
localStorage.removeItem("textdb.selectedTextId");
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[refreshTexts, selectedTextId]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 04:39:39 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 19:10:24 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 04:39:39 +01:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 18:47:20 +01:00
|
|
|
|
const handleTextContextMenu = useCallback(
|
2026-01-31 19:09:56 +01:00
|
|
|
|
async (event: React.MouseEvent, text: Text) => {
|
2026-01-31 18:47:20 +01:00
|
|
|
|
event.preventDefault();
|
2026-01-31 19:09:56 +01:00
|
|
|
|
const textId = text.id;
|
2026-01-31 18:47:20 +01:00
|
|
|
|
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: [
|
2026-01-31 19:09:56 +01:00
|
|
|
|
{
|
|
|
|
|
|
text: "Rename",
|
|
|
|
|
|
action: () => startEditingText(text)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: "Delete",
|
|
|
|
|
|
action: () => {
|
2026-02-01 04:39:48 +01:00
|
|
|
|
requestDeleteText(text).catch((error) => {
|
|
|
|
|
|
console.error("Failed to delete text", error);
|
2026-01-31 19:09:56 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-01-31 18:47:20 +01:00
|
|
|
|
{
|
|
|
|
|
|
text: "Move to folder",
|
|
|
|
|
|
items
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
});
|
|
|
|
|
|
await menu.popup(undefined, getCurrentWindow());
|
|
|
|
|
|
},
|
2026-02-01 04:39:48 +01:00
|
|
|
|
[folderPathList, handleMoveTextToFolder, requestDeleteText, startEditingText]
|
2026-01-31 19:09:56 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleFolderContextMenu = useCallback(
|
|
|
|
|
|
async (event: React.MouseEvent, folder: Folder) => {
|
|
|
|
|
|
event.preventDefault();
|
2026-02-01 01:06:42 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
];
|
2026-01-31 19:09:56 +01:00
|
|
|
|
const menu = await Menu.new({
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{
|
|
|
|
|
|
text: "Rename",
|
|
|
|
|
|
action: () => startEditingFolder(folder)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: "Delete",
|
|
|
|
|
|
action: () => {
|
2026-02-01 04:39:48 +01:00
|
|
|
|
requestDeleteFolder(folder).catch((error) => {
|
|
|
|
|
|
console.error("Failed to delete folder", error);
|
2026-01-31 19:09:56 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-01 01:06:42 +01:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: "Move to folder",
|
|
|
|
|
|
items: moveTargets
|
2026-01-31 19:09:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
});
|
|
|
|
|
|
await menu.popup(undefined, getCurrentWindow());
|
|
|
|
|
|
},
|
2026-02-01 04:39:48 +01:00
|
|
|
|
[folderPathList, handleMoveFolderToFolder, isDescendantFolder, requestDeleteFolder, startEditingFolder]
|
2026-01-31 18:47:20 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 00:37:25 +01:00
|
|
|
|
const createTextFromFile = useCallback(
|
|
|
|
|
|
async (filePath: string) => {
|
2026-01-31 00:42:19 +01:00
|
|
|
|
try {
|
2026-03-11 11:04:04 +01:00
|
|
|
|
const title = getImportedTitle(filePath);
|
2026-01-31 00:42:19 +01:00
|
|
|
|
const contents = await readTextFile(filePath);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
if (contents.includes("\u0000")) {
|
|
|
|
|
|
throw new Error("This file appears to be binary and cannot be opened as text.");
|
|
|
|
|
|
}
|
2026-02-01 01:50:54 +01:00
|
|
|
|
const { textId } = await createText(title, contents, null);
|
2026-01-31 00:42:19 +01:00
|
|
|
|
await refreshTexts();
|
|
|
|
|
|
setSelectedTextId(textId);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Failed to open text file", error);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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: () => {}
|
|
|
|
|
|
});
|
2026-01-31 00:42:19 +01:00
|
|
|
|
}
|
2026-01-31 00:37:25 +01:00
|
|
|
|
},
|
2026-02-01 01:50:54 +01:00
|
|
|
|
[refreshTexts]
|
2026-01-31 00:37:25 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 00:42:19 +01:00
|
|
|
|
const handleFilePaths = useCallback(
|
|
|
|
|
|
async (paths: string[]) => {
|
2026-01-31 01:04:56 +01:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const recent = recentOpenRef.current;
|
2026-03-11 11:04:04 +01:00
|
|
|
|
for (const path of paths) {
|
2026-01-31 01:04:56 +01:00
|
|
|
|
const key = path.toLowerCase();
|
|
|
|
|
|
const last = recent.get(key);
|
|
|
|
|
|
if (last && now - last < 1000) continue;
|
|
|
|
|
|
recent.set(key, now);
|
2026-01-31 00:42:19 +01:00
|
|
|
|
await createTextFromFile(path);
|
|
|
|
|
|
}
|
2026-01-31 01:04:56 +01:00
|
|
|
|
for (const [key, timestamp] of recent.entries()) {
|
|
|
|
|
|
if (now - timestamp > 2000) recent.delete(key);
|
|
|
|
|
|
}
|
2026-01-31 00:42:19 +01:00
|
|
|
|
},
|
|
|
|
|
|
[createTextFromFile]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 00:44:08 +01:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-31 00:37:25 +01:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-13 21:57:00 +01:00
|
|
|
|
const handleExportDatabase = useCallback(async () => {
|
|
|
|
|
|
setDbExportStatus(null);
|
|
|
|
|
|
const destinationPath = await save({
|
|
|
|
|
|
defaultPath: buildDatabaseExportFilename(),
|
|
|
|
|
|
filters: [{ name: "SQLite Database", extensions: ["db", "sqlite"] }]
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!destinationPath) return;
|
|
|
|
|
|
|
|
|
|
|
|
setDbExporting(true);
|
|
|
|
|
|
let snapshotPath: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (selectedTextId && isDirty && !isViewingHistory) {
|
|
|
|
|
|
await upsertDraft(selectedTextId, body, draftBaseVersionId);
|
|
|
|
|
|
setHasDraft(true);
|
|
|
|
|
|
setLastPersistedBody(body);
|
|
|
|
|
|
setSelectedHistoryId(`draft:${selectedTextId}`);
|
|
|
|
|
|
if (historyOpen) {
|
|
|
|
|
|
await refreshVersions();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const baseDir = await appDataDir();
|
|
|
|
|
|
const sourcePath = await join(baseDir, "text.db");
|
|
|
|
|
|
snapshotPath = await join(baseDir, `textdb-export-${Date.now()}.db`);
|
|
|
|
|
|
|
|
|
|
|
|
if (destinationPath === sourcePath) {
|
|
|
|
|
|
throw new Error("Choose a different destination than the live database file.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (await exists(snapshotPath)) {
|
|
|
|
|
|
await remove(snapshotPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (await exists(destinationPath)) {
|
|
|
|
|
|
await remove(destinationPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await exportDatabaseSnapshot(snapshotPath);
|
|
|
|
|
|
await copyFile(snapshotPath, destinationPath);
|
|
|
|
|
|
|
|
|
|
|
|
setDbExportStatus({
|
|
|
|
|
|
tone: "success",
|
|
|
|
|
|
message: `Database exported to ${destinationPath}`
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Failed to export database", error);
|
|
|
|
|
|
setDbExportStatus({
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
message: error instanceof Error ? error.message : "Failed to export database."
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (snapshotPath && (await exists(snapshotPath).catch(() => false))) {
|
|
|
|
|
|
await remove(snapshotPath).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
setDbExporting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [
|
|
|
|
|
|
body,
|
|
|
|
|
|
draftBaseVersionId,
|
|
|
|
|
|
historyOpen,
|
|
|
|
|
|
isDirty,
|
|
|
|
|
|
isViewingHistory,
|
|
|
|
|
|
refreshVersions,
|
|
|
|
|
|
selectedTextId
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
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) => {
|
2026-02-01 04:09:25 +01:00
|
|
|
|
if (event.defaultPrevented) return;
|
2026-03-11 11:04:04 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 04:24:03 +01:00
|
|
|
|
if (event.key === "Tab") return;
|
2026-01-30 23:14:06 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
2026-03-11 11:04:04 +01:00
|
|
|
|
}, [confirmState, handleSaveVersion, openDocumentSearch, settingsOpen]);
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
2026-02-01 01:00:01 +01:00
|
|
|
|
const renderTextItem = (text: Text) => (
|
2026-01-31 18:49:59 +01:00
|
|
|
|
<div
|
|
|
|
|
|
key={text.id}
|
|
|
|
|
|
className={`prompt-item${text.id === selectedTextId ? " is-active" : ""}`}
|
2026-01-31 19:09:56 +01:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (editingTextId === text.id) return;
|
|
|
|
|
|
setSelectedTextId(text.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onContextMenu={(event) => handleTextContextMenu(event, text)}
|
2026-01-31 18:49:59 +01:00
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
setSelectedTextId(text.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="prompt-item__content">
|
2026-01-31 19:09:56 +01:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-01-31 18:49:59 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="prompt-item__delete"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
2026-02-01 04:39:55 +01:00
|
|
|
|
requestDeleteText(text).catch((error) => {
|
|
|
|
|
|
console.error("Failed to delete text", error);
|
2026-01-31 18:49:59 +01:00
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-label="Delete text"
|
|
|
|
|
|
title="Delete text"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-31 18:52:43 +01:00
|
|
|
|
const renderFolder = (folder: Folder) => {
|
2026-01-31 18:49:59 +01:00
|
|
|
|
if (hasSearch && !visibleFolderIds?.has(folder.id)) return null;
|
|
|
|
|
|
const expanded = isFolderExpanded(folder.id);
|
2026-02-01 01:55:55 +01:00
|
|
|
|
const childEntries = entriesByParent.get(folder.id) ?? [];
|
2026-01-31 18:49:59 +01:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={folder.id} className="folder-node">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`folder-item${expanded ? " is-open" : ""}`}
|
2026-01-31 19:09:56 +01:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (editingFolderId === folder.id) return;
|
|
|
|
|
|
toggleFolderExpanded(folder.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onContextMenu={(event) => handleFolderContextMenu(event, folder)}
|
2026-01-31 18:49:59 +01:00
|
|
|
|
>
|
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>
|
2026-01-31 19:09:56 +01:00
|
|
|
|
{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();
|
2026-02-01 04:39:55 +01:00
|
|
|
|
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>
|
2026-01-31 18:49:59 +01:00
|
|
|
|
</div>
|
2026-02-01 02:07:35 +01:00
|
|
|
|
{expanded && childEntries.length > 0 ? (
|
2026-01-31 18:49:59 +01:00
|
|
|
|
<div className="folder-children">
|
2026-02-01 01:55:55 +01:00
|
|
|
|
{childEntries.map((entry) =>
|
|
|
|
|
|
entry.kind === "folder"
|
|
|
|
|
|
? renderFolder(entry.item)
|
|
|
|
|
|
: renderTextItem(entry.item)
|
|
|
|
|
|
)}
|
2026-01-31 18:49:59 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-30 23:14:06 +01:00
|
|
|
|
return (
|
2026-01-31 12:39:25 +01:00
|
|
|
|
<div className={`app app--theme-${theme}${sidebarCollapsed ? " app--sidebar-collapsed" : ""}`}>
|
2026-02-01 02:53:18 +01:00
|
|
|
|
{!sidebarCollapsed ? (
|
|
|
|
|
|
<aside className="sidebar">
|
|
|
|
|
|
<div className="sidebar__header">
|
|
|
|
|
|
<div className="sidebar__title-row">
|
|
|
|
|
|
<div className="app-title">TextDB</div>
|
2026-02-01 02:50:05 +01:00
|
|
|
|
</div>
|
2026-02-01 02:53:18 +01:00
|
|
|
|
<input
|
|
|
|
|
|
className="search"
|
|
|
|
|
|
placeholder="Search texts"
|
2026-03-11 11:07:17 +01:00
|
|
|
|
value={textSearch}
|
|
|
|
|
|
onChange={(event) => setTextSearch(event.target.value)}
|
2026-02-01 02:53:18 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</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)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-02-01 02:50:05 +01:00
|
|
|
|
</div>
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="sidebar__footer">
|
2026-02-01 02:50:05 +01:00
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
2026-02-01 03:15:10 +01:00
|
|
|
|
onClick={handleOpenText}
|
|
|
|
|
|
aria-label="Open text"
|
|
|
|
|
|
title="Open text"
|
2026-02-01 02:50:05 +01:00
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:15:10 +01:00
|
|
|
|
<img src={openFileIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:50:05 +01:00
|
|
|
|
</button>
|
2026-02-01 02:53:18 +01:00
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
|
|
|
|
|
onClick={handleNewText}
|
|
|
|
|
|
aria-label="New text"
|
|
|
|
|
|
title="New text"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:13:58 +01:00
|
|
|
|
<img src={newTextIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
2026-02-01 03:15:10 +01:00
|
|
|
|
onClick={handleNewFolder}
|
|
|
|
|
|
aria-label="New folder"
|
|
|
|
|
|
title="New folder"
|
2026-02-01 02:53:18 +01:00
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:15:10 +01:00
|
|
|
|
<img src={newFolderIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
|
|
|
|
|
onClick={() => setSettingsOpen(true)}
|
|
|
|
|
|
aria-label="Open settings"
|
|
|
|
|
|
title="Settings"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:13:58 +01:00
|
|
|
|
<img src={settingsIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
|
|
|
|
|
onClick={() => setSidebarCollapsed(true)}
|
|
|
|
|
|
aria-label="Collapse sidebar"
|
|
|
|
|
|
title="Collapse sidebar"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:13:58 +01:00
|
|
|
|
<img src={sidebarCollapseIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
) : null}
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
|
|
|
|
|
<main className="workspace">
|
2026-02-01 02:46:32 +01:00
|
|
|
|
<div className="workspace__body">
|
|
|
|
|
|
{!selectedTextId ? (
|
2026-02-01 02:53:43 +01:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
2026-02-01 02:46:32 +01:00
|
|
|
|
</div>
|
2026-02-01 02:53:43 +01:00
|
|
|
|
{sidebarCollapsed ? (
|
|
|
|
|
|
<div className="editor__footer">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
|
|
|
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
|
|
|
|
aria-label="Expand sidebar"
|
|
|
|
|
|
title="Expand sidebar"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:13:58 +01:00
|
|
|
|
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:43 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
2026-02-01 02:46:32 +01:00
|
|
|
|
) : (
|
|
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-01-31 12:45:26 +01:00
|
|
|
|
<img src={historyIconSrc} alt="" className="icon-button__img" />
|
2026-01-30 23:14:06 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-31 14:37:15 +01:00
|
|
|
|
<div
|
|
|
|
|
|
className={`editor__textarea-wrap${
|
2026-02-01 04:29:35 +01:00
|
|
|
|
markdownPreview && !splitView ? " editor__textarea-wrap--preview" : ""
|
|
|
|
|
|
}${markdownPreview && splitView ? " editor__textarea-wrap--split" : ""}`}
|
2026-01-31 14:37:15 +01:00
|
|
|
|
>
|
2026-02-01 04:00:29 +01:00
|
|
|
|
<div
|
|
|
|
|
|
ref={editorHostRef}
|
|
|
|
|
|
className={`editor__codemirror${
|
2026-02-01 04:29:35 +01:00
|
|
|
|
markdownPreview && !splitView ? " editor__codemirror--hidden" : ""
|
2026-02-01 04:00:29 +01:00
|
|
|
|
}`}
|
2026-02-01 03:49:41 +01:00
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`markdown-preview md-root${markdownPreview ? "" : " markdown-preview--hidden"}`}
|
2026-02-01 03:51:58 +01:00
|
|
|
|
dangerouslySetInnerHTML={{ __html: markdownHtml }}
|
2026-02-01 03:49:41 +01:00
|
|
|
|
onClick={handleMarkdownPreviewClick}
|
|
|
|
|
|
/>
|
2026-01-31 12:58:29 +01:00
|
|
|
|
</div>
|
2026-01-30 23:14:06 +01:00
|
|
|
|
|
|
|
|
|
|
<div className="editor__footer">
|
2026-02-01 02:53:18 +01:00
|
|
|
|
{sidebarCollapsed ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="icon-button"
|
|
|
|
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
|
|
|
|
aria-label="Expand sidebar"
|
|
|
|
|
|
title="Expand sidebar"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
2026-02-01 03:13:58 +01:00
|
|
|
|
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
|
2026-02-01 02:53:18 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
2026-03-11 11:04:04 +01:00
|
|
|
|
{hasText || isConverting ? (
|
2026-01-31 14:19:59 +01:00
|
|
|
|
<>
|
2026-03-11 11:04:04 +01:00
|
|
|
|
{hasText ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="button"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setMarkdownPreview((value) => !value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{markdownPreview
|
|
|
|
|
|
? splitView
|
|
|
|
|
|
? "Hide Preview"
|
|
|
|
|
|
: "Edit"
|
|
|
|
|
|
: "Preview Markdown"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
2026-01-31 16:14:13 +01:00
|
|
|
|
<button
|
|
|
|
|
|
className="button"
|
|
|
|
|
|
type="button"
|
2026-03-11 11:04:04 +01:00
|
|
|
|
onClick={isConverting ? handleCancelConversion : handleConvertToMarkdown}
|
|
|
|
|
|
disabled={isConverting ? false : !ollamaModel || isViewingHistory || !hasText}
|
2026-02-01 05:09:49 +01:00
|
|
|
|
>
|
2026-03-11 11:04:04 +01:00
|
|
|
|
{isConverting ? "Cancel Conversion" : "Convert to Markdown"}
|
2026-02-01 05:09:49 +01:00
|
|
|
|
</button>
|
2026-03-11 11:04:04 +01:00
|
|
|
|
{hasText ? (
|
|
|
|
|
|
<button className="button" onClick={handleExportText}>
|
|
|
|
|
|
Export Text
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{hasText && markdownPreview ? (
|
|
|
|
|
|
<button className="button" type="button" onClick={handlePrintMarkdown}>
|
|
|
|
|
|
Print
|
|
|
|
|
|
</button>
|
2026-01-31 14:19:59 +01:00
|
|
|
|
) : 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>
|
2026-03-11 11:04:04 +01:00
|
|
|
|
{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}
|
2026-02-01 02:46:32 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-01-30 23:14:06 +01:00
|
|
|
|
</main>
|
|
|
|
|
|
|
2026-01-31 12:39:25 +01:00
|
|
|
|
{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>
|
2026-02-01 05:10:00 +01:00
|
|
|
|
<div className="settings-panel__section-title">Interface</div>
|
2026-01-31 12:39:25 +01:00
|
|
|
|
<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) =>
|
2026-02-01 04:35:05 +01:00
|
|
|
|
setTheme(event.target.value as "dark" | "light")
|
2026-01-31 12:39:25 +01:00
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-02-01 04:35:05 +01:00
|
|
|
|
<option value="dark">Dark</option>
|
2026-01-31 12:39:25 +01:00
|
|
|
|
<option value="light">Bright</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-01-31 12:58:29 +01:00
|
|
|
|
<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>
|
2026-02-01 04:29:40 +01:00
|
|
|
|
<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>
|
2026-01-31 12:39:25 +01:00
|
|
|
|
<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>
|
2026-02-01 05:10:00 +01:00
|
|
|
|
<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}
|
|
|
|
|
|
>
|
2026-02-01 05:11:02 +01:00
|
|
|
|
{ollamaModels.length > 0 ? (
|
2026-02-01 05:10:00 +01:00
|
|
|
|
ollamaModels.map((model) => (
|
|
|
|
|
|
<option key={model} value={model}>
|
|
|
|
|
|
{model}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))
|
2026-02-01 05:11:02 +01:00
|
|
|
|
) : ollamaModel ? (
|
|
|
|
|
|
<option value={ollamaModel}>{ollamaModel}</option>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<option value="">
|
|
|
|
|
|
{ollamaLoading ? "Loading models…" : "No models found"}
|
|
|
|
|
|
</option>
|
2026-02-01 05:10:00 +01:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
2026-01-31 12:39:25 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|