Merge branch 'develop' into feat/llm-integration-part2

This commit is contained in:
perf3ct 2025-05-31 10:48:00 -07:00
commit ba98bd9097
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
50 changed files with 776 additions and 636 deletions

12
.gitattributes vendored
View File

@ -1,19 +1,21 @@
# Mark files as auto-generated to simplify reviews.
package-lock.json linguist-generated=true
**/package-lock.json linguist-generated=true
apps/server/src/assets/doc_notes/en/User[[:space:]]Guide/** linguist-generated
apps/server/src/assets/doc_notes/en/User[[:space:]]Guide/** linguist-generated=true
# Ignore from GitHub language stats.
apps/server/src/assets/doc_notes/en/User[[:space:]]Guide/**/*.html eol=lf
apps/server/src/assets/doc_notes/** linguist-vendored=true
apps/edit-docs/demo/** linguist-vendored=true
docs/** linguist-vendored=true
# Normalize line endings.
docs/**/*.md eol=lf
docs/**/*.json eol=lf
demo/**/*.html eol=lf
demo/**/*.json eol=lf
demo/**/*.svg eol=lf
demo/**/*.txt eol=lf
demo/**/*.js eol=lf
demo/**/*.css eol=lf
*.sh eol=lf
apps/client/src/libraries/** linguist-vendored

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22.16.0

View File

@ -1,7 +0,0 @@
{
"templates": {
"default": {
"includeDate": false
}
}
}

View File

@ -38,7 +38,7 @@
"@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "4.4.0",
"@types/express": "5.0.1",
"@types/node": "22.15.24",
"@types/node": "22.15.29",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.1.4",
"eslint": "9.27.0",

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/public" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/spec-es6" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/src/public/app-dist" />
<excludeFolder url="file://$MODULE_DIR$/libraries" />
<excludeFolder url="file://$MODULE_DIR$/libraries" />
<excludeFolder url="file://$MODULE_DIR$/docs" />
<excludeFolder url="file://$MODULE_DIR$/bin/better-sqlite3" />
<excludeFolder url="file://$MODULE_DIR$/data" />
<excludeFolder url="file://$MODULE_DIR$/.flatpak-builder" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="@types/jquery" level="application" />
</component>
</module>

View File

@ -274,15 +274,15 @@
"revision_last_edited": "此修订版本上次编辑于 {{date}}",
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
"no_revisions": "此笔记暂无修订版本...",
"restore_button": "",
"restore_button": "恢复",
"confirm_restore": "您是否要恢复此修订版本?这将使用此修订版本覆盖笔记的当前标题和内容。",
"delete_button": "",
"delete_button": "删除",
"confirm_delete": "您是否要删除此修订版本?",
"revisions_deleted": "笔记修订版本已删除。",
"revision_restored": "笔记修订版本已恢复。",
"revision_deleted": "笔记修订版本已删除。",
"snapshot_interval": "笔记快照保存间隔: {{seconds}}秒。",
"maximum_revisions": "当前笔记的最历史数量: {{number}}。",
"maximum_revisions": "当前笔记的最历史数量: {{number}}。",
"settings": "笔记修订设置",
"download_button": "下载",
"mime": "MIME 类型:",
@ -806,7 +806,7 @@
"open_full": "展开显示",
"collapse": "折叠到正常大小",
"title": "笔记地图",
"fix-nodes": "修复节点",
"fix-nodes": "固定节点",
"link-distance": "链接距离"
},
"note_paths": {
@ -1213,7 +1213,7 @@
"color": "字体颜色",
"bg_color": "背景颜色",
"visibility_title": "高亮列表可见性",
"visibility_description": "您可以通过添加 #hideHighlightWidget 标签来隐藏个笔记的高亮小部件。",
"visibility_description": "您可以通过添加 #hideHighlightWidget 标签来隐藏个笔记的高亮小部件。",
"shortcut_info": "您可以在选项 -> 快捷键中为快速切换右侧面板(包括高亮列表)配置键盘快捷键(名称为 'toggleRightPane')。"
},
"table_of_contents": {
@ -1547,7 +1547,7 @@
"close_other_tabs": "关闭其他标签页",
"close_right_tabs": "关闭右侧标签页",
"close_all_tabs": "关闭所有标签页",
"reopen_last_tab": "重新打开最后一个关闭的标签页",
"reopen_last_tab": "重新打开关闭的标签页",
"move_tab_to_new_window": "将此标签页移动到新窗口",
"copy_tab_to_new_window": "将此标签页复制到新窗口",
"new_tab": "新标签页"
@ -1616,7 +1616,7 @@
"auto-detect-language": "自动检测"
},
"highlighting": {
"title": "",
"title": "代码块",
"description": "控制文本笔记中代码块的语法高亮,代码笔记不会受到影响。",
"color-scheme": "颜色方案"
},
@ -1627,21 +1627,21 @@
"theme_group_dark": "深色主题"
},
"classic_editor_toolbar": {
"title": "格式"
"title": "格式"
},
"editor": {
"title": "编辑器"
},
"editing": {
"editor_type": {
"label": "格式工具栏",
"label": "格式工具栏",
"floating": {
"title": "浮动",
"description": "编辑工具出现在光标附近;"
},
"fixed": {
"title": "固定",
"description": "编辑工具出现在 \"格式\" 功能区标签中。"
"description": "编辑工具出现在 \"格式\" 功能区标签中。"
},
"multiline-toolbar": "如果工具栏无法完全显示,则分多行显示。"
}

View File

@ -1,27 +0,0 @@
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import CommandButtonWidget from "./command_button.js";
export default class CreateAiChatButton extends CommandButtonWidget {
constructor() {
super();
this.icon("bx bx-bot")
.title(t("ai.create_new_ai_chat"))
.titlePlacement("bottom")
.command("createAiChat")
.class("icon-action");
}
isEnabled() {
return options.get("aiEnabled") === "true";
}
async refreshWithNote() {
if (this.isEnabled()) {
this.$widget.show();
} else {
this.$widget.hide();
}
}
}

View File

@ -186,7 +186,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book"].includes(note.type));
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type));

View File

@ -188,7 +188,7 @@ export default class FindWidget extends NoteContextAwareWidget {
return;
}
if (!["text", "code", "render"].includes(this.note?.type ?? "")) {
if (!["text", "code", "render", "mindMap"].includes(this.note?.type ?? "")) {
return;
}
@ -250,6 +250,8 @@ export default class FindWidget extends NoteContextAwareWidget {
case "text":
const readOnly = await this.noteContext?.isReadOnly();
return readOnly ? this.htmlHandler : this.textHandler;
case "mindMap":
return this.htmlHandler;
default:
console.warn("FindWidget: Unsupported note type for find widget", this.note?.type);
}
@ -352,7 +354,7 @@ export default class FindWidget extends NoteContextAwareWidget {
}
isEnabled() {
return super.isEnabled() && ["text", "code", "render"].includes(this.note?.type ?? "");
return super.isEnabled() && ["text", "code", "render", "mindMap"].includes(this.note?.type ?? "");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@ -85,7 +85,7 @@ export default class FindInHtml {
if (this.$results?.length) {
const $current = this.$results.eq(this.currentIndex);
this.$results.removeClass(FIND_RESULT_SELECTED_CSS_CLASSNAME);
$current[0].scrollIntoView();
$current[0].scrollIntoView({ block: 'center', inline: 'center'});
$current.addClass(FIND_RESULT_SELECTED_CSS_CLASSNAME);
}
}

View File

@ -38,7 +38,6 @@ const NOTE_TYPES: NoteTypeMapping[] = [
// Misc note types
{ type: "render", mime: "", title: t("note_types.render-note"), selectable: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), selectable: true },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), selectable: true },
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), selectable: true },
@ -50,7 +49,8 @@ const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "image", title: t("note_types.image"), selectable: false },
{ type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), selectable: false },
{ type: "search", title: t("note_types.saved-search"), selectable: false }
{ type: "search", title: t("note_types.saved-search"), selectable: false },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), selectable: false }
];
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type);

View File

@ -378,16 +378,45 @@ export default class TabRowWidget extends BasicWidget {
}
scrollTabContainer(direction: number, behavior: ScrollBehavior = "smooth") {
const currentScrollLeft = this.$tabScrollingContainer[0]?.scrollLeft;
this.$tabScrollingContainer[0].scrollTo({
left: currentScrollLeft + direction,
this.$tabScrollingContainer[0].scrollBy({
left: direction,
behavior
});
};
setupScrollEvents() {
this.$tabScrollingContainer[0].addEventListener('wheel', (event) => {
this.scrollTabContainer(event.deltaY * 1.5);
let deltaX = 0;
let isScrolling = false;
const stepScroll = () => {
if (Math.abs(deltaX) > 5) {
const step = Math.round(deltaX * 0.2);
deltaX -= step;
this.scrollTabContainer(step, "instant");
requestAnimationFrame(stepScroll);
} else {
this.scrollTabContainer(deltaX, "instant");
deltaX = 0;
isScrolling = false;
}
};
this.$tabScrollingContainer[0].addEventListener('wheel', async (event) => {
if (!event.shiftKey && event.deltaX === 0) {
event.preventDefault();
// Clamp deltaX between TAB_CONTAINER_MIN_WIDTH and TAB_CONTAINER_MIN_WIDTH * 3
deltaX += Math.sign(event.deltaY) * Math.max(Math.min(Math.abs(event.deltaY), TAB_CONTAINER_MIN_WIDTH * 3), TAB_CONTAINER_MIN_WIDTH);
if (!isScrolling) {
isScrolling = true;
stepScroll();
}
} else if (event.shiftKey) {
event.preventDefault();
if (event.deltaY > 0) {
await appContext.tabManager.activateNextTabCommand();
} else {
await appContext.tabManager.activatePreviousTabCommand();
}
this.activeTabEl.scrollIntoView();
}
});
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-200));

View File

@ -286,4 +286,13 @@ export default class MindMapWidget extends TypeWidget {
utils.downloadSvgAsPng(this.note.title, svg);
}
async executeWithContentElementEvent({ resolve, ntxId }: EventData<"executeWithContentElement">) {
if (!this.isNoteContext(ntxId)) {
return;
}
await this.initialized;
resolve(this.$content.find('.main-node-container'));
}
}

View File

@ -22,6 +22,12 @@ async function main() {
electron.app.commandLine.appendSwitch("enable-experimental-web-platform-features");
electron.app.commandLine.appendSwitch("lang", options.getOptionOrNull("formattingLocale") ?? "en");
// Electron 36 crashes with "Using GTK 2/3 and GTK 4 in the same process is not supported" on some distributions.
// See https://github.com/electron/electron/issues/46538 for more info.
if (process.platform === "linux") {
electron.app.commandLine.appendSwitch("gtk-version", "3");
}
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.

View File

@ -1,5 +1,3 @@
#!/usr/bin/env node
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import dumpService from "./inc/dump.js";

View File

@ -115,7 +115,7 @@ test("Search works when dismissing a tab", async ({ page, context }) => {
await app.getTab(0).click();
await app.openAndClickNoteActionMenu("Search in note");
await expect(app.findAndReplaceWidget).toBeVisible();
await expect(app.findAndReplaceWidget.first()).toBeVisible();
});
test("New tab displays workspaces", async ({ page, context }) => {

View File

@ -87,8 +87,8 @@
"mime-types": "3.0.1",
"multer": "2.0.0",
"normalize-strings": "1.1.1",
"ollama": "0.5.15",
"openai": "4.103.0",
"ollama": "0.5.16",
"openai": "4.104.0",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",

View File

@ -21,7 +21,7 @@ fi
echo "Selected Arch: $ARCH"
# Set Node.js version and architecture-specific filename
NODE_VERSION=22.14.0
NODE_VERSION=22.16.0
script_dir=$(realpath $(dirname $0))
BUILD_DIR="$script_dir/../dist"
@ -43,7 +43,7 @@ rm -rf $BUILD_DIR/node/lib/node_modules/{npm,corepack} \
$BUILD_DIR/node_modules/electron* \
$BUILD_DIR/electron*.{js,map}
printf "#!/bin/sh\n./node/bin/node src/main\n" > $BUILD_DIR/trilium.sh
printf "#!/bin/sh\n./node/bin/node main.cjs\n" > $BUILD_DIR/trilium.sh
chmod 755 $BUILD_DIR/trilium.sh
VERSION=`jq -r ".version" package.json`

View File

@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS "blobs" (
`blobId` TEXT NOT NULL,
`content` TEXT NULL DEFAULT NULL,
`dateModified` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
PRIMARY KEY(`blobId`)
);
ALTER TABLE notes ADD blobId TEXT DEFAULT NULL;
ALTER TABLE note_revisions ADD blobId TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_note_revisions_blobId on note_revisions (blobId);

View File

@ -1,4 +0,0 @@
DROP TABLE note_contents;
DROP TABLE note_revision_contents;
DELETE FROM entity_changes WHERE entityName IN ('note_contents', 'note_revision_contents');

View File

@ -1,26 +0,0 @@
CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
`noteId` TEXT NOT NULL,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,
`utcDateCreated` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
`dateLastEdited` TEXT NOT NULL,
`dateCreated` TEXT NOT NULL);
INSERT INTO revisions (revisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId)
SELECT noteRevisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId FROM note_revisions;
DROP TABLE note_revisions;
CREATE INDEX `IDX_revisions_noteId` ON `revisions` (`noteId`);
CREATE INDEX `IDX_revisions_utcDateCreated` ON `revisions` (`utcDateCreated`);
CREATE INDEX `IDX_revisions_utcDateLastEdited` ON `revisions` (`utcDateLastEdited`);
CREATE INDEX `IDX_revisions_dateCreated` ON `revisions` (`dateCreated`);
CREATE INDEX `IDX_revisions_dateLastEdited` ON `revisions` (`dateLastEdited`);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
UPDATE entity_changes SET entityName = 'revisions' WHERE entityName = 'note_revisions';

View File

@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS "attachments"
(
attachmentId TEXT not null primary key,
ownerId TEXT not null,
role TEXT not null,
mime TEXT not null,
title TEXT not null,
isProtected INT not null DEFAULT 0,
position INT default 0 not null,
blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL,
utcDateModified TEXT not null,
utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null,
deleteId TEXT DEFAULT NULL);
CREATE INDEX IDX_attachments_ownerId_role
on attachments (ownerId, role);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince
on attachments (utcDateScheduledForErasureSince);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);

View File

@ -1,2 +0,0 @@
DELETE FROM options WHERE name = 'hideIncludedImages_main';
DELETE FROM entity_changes WHERE entityName = 'options' AND entityId = 'hideIncludedImages_main';

View File

@ -1,2 +0,0 @@
UPDATE options SET name = 'openNoteContexts' WHERE name = 'openTabs';
UPDATE entity_changes SET entityId = 'openNoteContexts' WHERE entityName = 'options' AND entityId = 'openTabs';

View File

@ -1 +0,0 @@
SELECT 1;

View File

@ -1,14 +0,0 @@
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'X');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'X');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'X');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'X');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'X') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'Y') WHERE entityName = 'blobs';

View File

@ -1,3 +0,0 @@
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);

View File

@ -1 +0,0 @@
UPDATE attributes SET value = 'contentAndAttachmentsAndRevisionsSize' WHERE name = 'orderBy' AND value = 'noteSize';

View File

@ -1,2 +0,0 @@
-- emergency disabling of image compression since it appears to make problems in migration to 0.61
UPDATE options SET value = 'false' WHERE name = 'compressImages';

View File

@ -1,17 +0,0 @@
-- + is normally replaced by X and / by Y, but this can temporarily cause UNIQUE key exception
-- this might create blob duplicates, but cleanup will eventually take care of it
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'A');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'B');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'A');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'B');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'A');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'B');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'A');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'B');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'A') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'B') WHERE entityName = 'blobs';

View File

@ -1,14 +0,0 @@
-- Add the oauth user data table
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);

View File

@ -1,46 +0,0 @@
-- Add tables for vector embeddings storage and management
-- This migration adds embedding support to the main document.db database
-- Store embeddings for notes
CREATE TABLE IF NOT EXISTS "note_embeddings" (
"embedId" TEXT NOT NULL PRIMARY KEY,
"noteId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"modelId" TEXT NOT NULL,
"dimension" INTEGER NOT NULL,
"embedding" BLOB NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);
CREATE INDEX "IDX_note_embeddings_noteId" ON "note_embeddings" ("noteId");
CREATE INDEX "IDX_note_embeddings_providerId_modelId" ON "note_embeddings" ("providerId", "modelId");
-- Table to track which notes need embedding updates
CREATE TABLE IF NOT EXISTS "embedding_queue" (
"noteId" TEXT NOT NULL PRIMARY KEY,
"operation" TEXT NOT NULL, -- CREATE, UPDATE, DELETE
"dateQueued" TEXT NOT NULL,
"utcDateQueued" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastAttempt" TEXT NULL,
"error" TEXT NULL,
"failed" INTEGER NOT NULL DEFAULT 0,
"isProcessing" INTEGER NOT NULL DEFAULT 0
);
-- Table to store embedding provider configurations
CREATE TABLE IF NOT EXISTS "embedding_providers" (
"providerId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL, -- JSON config object
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);

View File

@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data TEXT,
expires INTEGER
);

View File

@ -3,8 +3,9 @@
<h2>Steps</h2>
<ul>
<li>SSH into your server</li>
<li>use <code>wget</code> (or <code>curl</code>) to download latest <code>TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz</code> (notice <code>-Server</code> suffix)
on your server.</li>
<li>use <code>wget</code> (or <code>curl</code>) to download latest <code>TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz</code> (copy
link from <a href="https://github.com/TriliumNext/Notes/releases">release page</a>,
notice <code>-Server</code> suffix) on your server.</li>
<li>unpack the archive, e.g. using <code>tar -xf -d TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz</code>
</li>
<li><code>cd trilium-linux-x64-server</code>
@ -17,7 +18,9 @@
<p>The problem with above steps is that once you close the SSH connection,
the Trilium process is terminated. To avoid that, you have two options:</p>
<ul>
<li>Kill it (with e.g. <kbd>Ctrl</kbd> + <kbd>C</kbd>) and run again like this: <code>nohup ./trilium &amp;</code>.</li>
<li>Kill it (with e.g. <kbd>Ctrl</kbd> + <kbd>C</kbd>) and run again like this: <code>nohup ./trilium.sh &amp;</code>.
(nohup keeps the process running in the background, <code>&amp;</code> runs
it in the background)</li>
<li>Configure systemd to automatically run Trilium in the background on every
boot</li>
</ul>

View File

@ -19,7 +19,7 @@
<div id="toast-container" class="d-flex flex-column justify-content-center align-items-center"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
<%- include("./partials/windowGlobal.ejs", locals) %>

View File

@ -1,5 +1,5 @@
import sql from "../../../services/sql.js";
import utils from "../../../services/utils.js";
import sql from "../services/sql.js";
import utils from "../services/utils.js";
interface NoteContentsRow {
noteId: string;

View File

@ -1,8 +1,8 @@
import becca from "../../../becca/becca.js";
import becca_loader from "../../../becca/becca_loader.js";
import cls from "../../../services/cls.js";
import log from "../../../services/log.js";
import sql from "../../../services/sql.js";
import becca from "../becca/becca.js";
import becca_loader from "../becca/becca_loader.js";
import cls from "../services/cls.js";
import log from "../services/log.js";
import sql from "../services/sql.js";
export default () => {
cls.init(() => {

View File

@ -0,0 +1,295 @@
/**
* @module
*
* Contains all the migrations that are run on the database.
*/
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Session store
{
version: 231,
sql: /*sql*/`\
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data TEXT,
expires INTEGER
);
`
},
// Add tables for vector embeddings storage and management
// This migration adds embedding support to the main document.db database
{
version: 230,
sql: /*sql*/`\
-- Store embeddings for notes
CREATE TABLE IF NOT EXISTS "note_embeddings" (
"embedId" TEXT NOT NULL PRIMARY KEY,
"noteId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"modelId" TEXT NOT NULL,
"dimension" INTEGER NOT NULL,
"embedding" BLOB NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);
CREATE INDEX "IDX_note_embeddings_noteId" ON "note_embeddings" ("noteId");
CREATE INDEX "IDX_note_embeddings_providerId_modelId" ON "note_embeddings" ("providerId", "modelId");
-- Table to track which notes need embedding updates
CREATE TABLE IF NOT EXISTS "embedding_queue" (
"noteId" TEXT NOT NULL PRIMARY KEY,
"operation" TEXT NOT NULL, -- CREATE, UPDATE, DELETE
"dateQueued" TEXT NOT NULL,
"utcDateQueued" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastAttempt" TEXT NULL,
"error" TEXT NULL,
"failed" INTEGER NOT NULL DEFAULT 0,
"isProcessing" INTEGER NOT NULL DEFAULT 0
);
-- Table to store embedding provider configurations
CREATE TABLE IF NOT EXISTS "embedding_providers" (
"providerId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL, -- JSON config object
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);
`
},
// add the oauth user data table
{
version: 229,
sql: /*sql*/`\
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);
`
},
// fix blob IDs
{
version: 228,
sql: /*sql*/`\
-- + is normally replaced by X and / by Y, but this can temporarily cause UNIQUE key exception
-- this might create blob duplicates, but cleanup will eventually take care of it
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'A');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'B');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'A');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'B');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'A');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'B');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'A');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'B');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'A') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'B') WHERE entityName = 'blobs';
`
},
// disable image compression
{
version: 227,
sql: /*sql*/`\
-- emergency disabling of image compression since it appears to make problems in migration to 0.61
UPDATE options SET value = 'false' WHERE name = 'compressImages';
`
},
// rename note size label
{
version: 226,
sql: /*sql*/`\
UPDATE attributes SET value = 'contentAndAttachmentsAndRevisionsSize' WHERE name = 'orderBy' AND value = 'noteSize';
`
},
// create blob ID indices
{
version: 225,
sql: /*sql*/`\
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);
`
},
// fix blob IDs
{
version: 224,
sql: /*sql*/`\
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'X');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'X');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'X');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'X');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'X') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'Y') WHERE entityName = 'blobs';
`
},
// no operation
{
version: 223,
sql: /*sql*/`\
SELECT 1;
`
},
// rename open tabs to open note contexts
{
version: 222,
sql: /*sql*/`\
UPDATE options SET name = 'openNoteContexts' WHERE name = 'openTabs';
UPDATE entity_changes SET entityId = 'openNoteContexts' WHERE entityName = 'options' AND entityId = 'openTabs';
`
},
// remove hide included images option
{
version: 221,
sql: /*sql*/`\
DELETE FROM options WHERE name = 'hideIncludedImages_main';
DELETE FROM entity_changes WHERE entityName = 'options' AND entityId = 'hideIncludedImages_main';
`
},
// migrate images to attachments
{
version: 220,
module: () => import("./0220__migrate_images_to_attachments.js")
},
// attachments
{
version: 219,
sql: /*sql*/`\
CREATE TABLE IF NOT EXISTS "attachments"
(
attachmentId TEXT not null primary key,
ownerId TEXT not null,
role TEXT not null,
mime TEXT not null,
title TEXT not null,
isProtected INT not null DEFAULT 0,
position INT default 0 not null,
blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL,
utcDateModified TEXT not null,
utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null,
deleteId TEXT DEFAULT NULL);
CREATE INDEX IDX_attachments_ownerId_role
on attachments (ownerId, role);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince
on attachments (utcDateScheduledForErasureSince);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);
`
},
// rename note revision to revision
{
version: 218,
sql: /*sql*/`\
CREATE TABLE IF NOT EXISTS "revisions" (
revisionId TEXT NOT NULL PRIMARY KEY,
noteId TEXT NOT NULL,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
title TEXT NOT NULL,
isProtected INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
utcDateLastEdited TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
dateLastEdited TEXT NOT NULL,
dateCreated TEXT NOT NULL
);
INSERT INTO revisions (revisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId)
SELECT noteRevisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId FROM note_revisions;
DROP TABLE note_revisions;
CREATE INDEX IDX_revisions_noteId ON revisions (noteId);
CREATE INDEX IDX_revisions_utcDateCreated ON revisions (utcDateCreated);
CREATE INDEX IDX_revisions_utcDateLastEdited ON revisions (utcDateLastEdited);
CREATE INDEX IDX_revisions_dateCreated ON revisions (dateCreated);
CREATE INDEX IDX_revisions_dateLastEdited ON revisions (dateLastEdited);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
UPDATE entity_changes SET entityName = 'revisions' WHERE entityName = 'note_revisions';
`
},
// drop content tables
{
version: 217,
sql: /*sql*/`\
DROP TABLE note_contents;
DROP TABLE note_revision_contents;
DELETE FROM entity_changes WHERE entityName IN ('note_contents', 'note_revision_contents');
`
},
{
version: 216,
module: async () => import("./0216__move_content_into_blobs.js")
},
// content structure
{
version: 215,
sql: /*sql*/`\
CREATE TABLE IF NOT EXISTS "blobs" (
blobId TEXT NOT NULL,
content TEXT NULL DEFAULT NULL,
dateModified TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
PRIMARY KEY (blobId)
);
ALTER TABLE notes ADD blobId TEXT DEFAULT NULL;
ALTER TABLE note_revisions ADD blobId TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_note_revisions_blobId on note_revisions (blobId);
`
}
];
export default MIGRATIONS;
interface Migration {
version: number;
}
interface SqlMigration extends Migration {
sql: string;
}
interface JsMigration extends Migration {
module: () => Promise<{ default: () => void }>;
}

View File

@ -72,7 +72,7 @@ export default function buildLaunchBarConfig() {
id: "_lbLlmChat",
title: t("hidden-subtree.llm-chat-title"),
type: "launcher",
command: "createAiChat",
builtinWidget: "aiChatLauncher",
icon: "bx bx-bot",
attributes: [
{ type: "label", name: "desktopOnly" }

View File

@ -1,25 +1,19 @@
import backupService from "./backup.js";
import sql from "./sql.js";
import fs from "fs";
import log from "./log.js";
import { crash } from "./utils.js";
import resourceDir from "./resource_dir.js";
import appInfo from "./app_info.js";
import cls from "./cls.js";
import { t } from "i18next";
import { join } from "path";
import MIGRATIONS from "../migrations/migrations.js";
interface MigrationInfo {
dbVersion: number;
name: string;
file: string;
type: "sql" | "js" | "ts" | string;
/**
* Contains the JavaScript/TypeScript migration as a callback method that must be called to trigger the migration.
* The method cannot be async since it runs in an SQL transaction.
* For SQL migrations, this value is falsy.
* If string, then the migration is an SQL script that will be executed.
* If a function, then the migration is a JavaScript/TypeScript module that will be executed.
*/
module?: () => void;
migration: string | (() => void);
}
async function migrate() {
@ -37,7 +31,6 @@ async function migrate() {
);
const migrations = await prepareMigrations(currentDbVersion);
migrations.sort((a, b) => a.dbVersion - b.dbVersion);
// all migrations are executed in one transaction - upgrade either succeeds, or the user can stay at the old version
// otherwise if half of the migrations succeed, user can't use any version - DB is too "new" for the old app,
@ -76,53 +69,37 @@ async function migrate() {
}
async function prepareMigrations(currentDbVersion: number): Promise<MigrationInfo[]> {
const migrationFiles = fs.readdirSync(resourceDir.MIGRATIONS_DIR) ?? [];
MIGRATIONS.sort((a, b) => a.version - b.version);
const migrations: MigrationInfo[] = [];
for (const file of migrationFiles) {
const match = file.match(/^([0-9]{4})__([a-zA-Z0-9_ ]+)\.(sql|js|ts)$/);
if (!match) {
continue;
}
const dbVersion = parseInt(match[1]);
for (const migration of MIGRATIONS) {
const dbVersion = migration.version;
if (dbVersion > currentDbVersion) {
const name = match[2];
const type = match[3];
const migration: MigrationInfo = {
dbVersion: dbVersion,
name: name,
file: file,
type: type
};
if (type === "js" || type === "ts") {
if ("sql" in migration) {
migrations.push({
dbVersion,
migration: migration.sql
});
} else {
// Due to ESM imports, the migration file needs to be imported asynchronously and thus cannot be loaded at migration time (since migration is not asynchronous).
// As such we have to preload the ESM.
// Going back to the original approach but making it webpack-compatible
const importPath = join(resourceDir.MIGRATIONS_DIR, file);
migration.module = (await import(importPath)).default;
migrations.push({
dbVersion,
migration: (await migration.module()).default
});
}
migrations.push(migration);
}
}
return migrations;
}
function executeMigration(mig: MigrationInfo) {
if (mig.module) {
console.log("Migration with JS module");
mig.module();
} else if (mig.type === "sql") {
const migrationSql = fs.readFileSync(`${resourceDir.MIGRATIONS_DIR}/${mig.file}`).toString("utf8");
console.log(`Migration with SQL script: ${migrationSql}`);
sql.executeScript(migrationSql);
function executeMigration({ migration }: MigrationInfo) {
if (typeof migration === "string") {
console.log(`Migration with SQL script: ${migration}`);
sql.executeScript(migration);
} else {
throw new Error(`Unknown migration type '${mig.type}'`);
}
console.log("Migration with JS module");
migration();
};
}
function getDbVersion() {

View File

@ -14,16 +14,8 @@ if (!fs.existsSync(DB_INIT_DIR)) {
process.exit(1);
}
const MIGRATIONS_DIR = path.resolve(DB_INIT_DIR, "migrations");
if (!fs.existsSync(MIGRATIONS_DIR)) {
log.error(`Could not find migration directory: ${MIGRATIONS_DIR}`);
process.exit(1);
}
export default {
RESOURCE_DIR,
MIGRATIONS_DIR,
DB_INIT_DIR,
ELECTRON_APP_ROOT_DIR
};

View File

@ -1,4 +1,3 @@
#!/usr/bin/env node
import fs from "fs";
import http from "http";
import https from "https";

View File

@ -59,6 +59,7 @@
* [Text notes: add a way to move up and down text lines via a keyboard shortcut](https://github.com/TriliumNext/Notes/issues/1002) by @dogfuntom
* [improve tab scroll UX by switching from instant to smooth behavior](https://github.com/TriliumNext/Notes/pull/2030) by @SiriusXT
* Calendar view: display calendar view if `#viewType=calendar` is set.
* [Mind map: add search support](https://github.com/TriliumNext/Notes/pull/2055) by @SiriusXT
## 📖 Documentation
@ -67,6 +68,7 @@
* Add documentation around setting the various environment variables to control upload size limit by @perfectra1n
* README improvements by @FliegendeWurst
* Improved the documentation of text note keyboard shortcuts (including adding missing shortcuts).
* Improvements to "Packaged version for Linux" by @HersheyStormBottle
## 🌍 Internationalization

View File

@ -4,7 +4,7 @@ This is essentially Trilium sources + node modules + node.js runtime packaged in
## Steps
* SSH into your server
* use `wget` (or `curl`) to download latest `TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz` (notice `-Server` suffix) on your server.
* use `wget` (or `curl`) to download latest `TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz` (copy link from [release page](https://github.com/TriliumNext/Notes/releases), notice `-Server` suffix) on your server.
* unpack the archive, e.g. using `tar -xf -d TriliumNextNotes-Server-[VERSION]-linux-x64.tar.xz`
* `cd trilium-linux-x64-server`
* `./trilium.sh`
@ -12,7 +12,7 @@ This is essentially Trilium sources + node modules + node.js runtime packaged in
The problem with above steps is that once you close the SSH connection, the Trilium process is terminated. To avoid that, you have two options:
* Kill it (with e.g. <kbd>Ctrl</kbd> + <kbd>C</kbd>) and run again like this: `nohup ./trilium &`.
* Kill it (with e.g. <kbd>Ctrl</kbd> + <kbd>C</kbd>) and run again like this: `nohup ./trilium.sh &`. (nohup keeps the process running in the background, `&` runs it in the background)
* Configure systemd to automatically run Trilium in the background on every boot
## Configure Trilium to auto-run on boot with systemd

View File

@ -40,7 +40,7 @@
"@playwright/test": "^1.36.0",
"@triliumnext/server": "workspace:*",
"@types/express": "^4.17.21",
"@types/node": "22.15.24",
"@types/node": "22.15.29",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0",
"chalk": "5.4.1",

View File

@ -30,7 +30,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/view": "6.37.0",
"@codemirror/view": "6.37.1",
"@fsegurai/codemirror-theme-abcdef": "6.1.4",
"@fsegurai/codemirror-theme-abyss": "6.1.4",
"@fsegurai/codemirror-theme-android-studio": "6.1.4",

View File

@ -1,5 +1,5 @@
import { indentLess, indentMore } from "@codemirror/commands";
import { EditorSelection, EditorState, SelectionRange, type ChangeSpec } from "@codemirror/state";
import { EditorSelection, EditorState, SelectionRange, type Transaction, type ChangeSpec } from "@codemirror/state";
import type { KeyBinding } from "@codemirror/view";
/**
@ -19,57 +19,88 @@ const smartIndentWithTab: KeyBinding[] = [
}
const { selection } = state;
const changes: ChangeSpec[] = [];
const newSelections: SelectionRange[] = [];
// Step 1: Handle non-empty selections → replace with tab
if (selection.ranges.some(range => !range.empty)) {
for (let range of selection.ranges) {
changes.push({ from: range.from, to: range.to, insert: "\t" });
newSelections.push(EditorSelection.cursor(range.from + 1));
// If multiple lines are selected, insert a tab character at the start of each line
// and move the cursor to the position after the tab character.
const linesCovered = new Set<number>();
for (const range of selection.ranges) {
const startLine = state.doc.lineAt(range.from);
const endLine = state.doc.lineAt(range.to);
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber++) {
linesCovered.add(lineNumber);
}
}
dispatch(
state.update({
changes,
selection: EditorSelection.create(newSelections),
scrollIntoView: true,
userEvent: "input"
})
);
return true;
if (linesCovered.size > 1) {
// Multiple lines are selected, indent each line.
return indentMore({ state, dispatch });
} else {
return handleSingleLineSelection(state, dispatch);
}
}
// Step 2: Handle empty selections
for (let range of selection.ranges) {
const line = state.doc.lineAt(range.head);
const beforeCursor = state.doc.sliceString(line.from, range.head);
if (/^\s*$/.test(beforeCursor)) {
// Only whitespace before cursor → indent line
return indentMore({ state, dispatch });
} else {
// Insert tab character at cursor
changes.push({ from: range.head, to: range.head, insert: "\t" });
newSelections.push(EditorSelection.cursor(range.head + 1));
}
}
if (changes.length) {
dispatch(
state.update({
changes,
selection: EditorSelection.create(newSelections),
scrollIntoView: true,
userEvent: "input"
})
);
return true;
}
return false;
return handleEmptySelections(state, dispatch);
},
shift: indentLess
},
]
export default smartIndentWithTab;
function handleSingleLineSelection(state: EditorState, dispatch: (transaction: Transaction) => void) {
const changes: ChangeSpec[] = [];
const newSelections: SelectionRange[] = [];
// Single line selection, replace with tab.
for (let range of state.selection.ranges) {
changes.push({ from: range.from, to: range.to, insert: "\t" });
newSelections.push(EditorSelection.cursor(range.from + 1));
}
dispatch(
state.update({
changes,
selection: EditorSelection.create(newSelections),
scrollIntoView: true,
userEvent: "input"
})
);
return true;
}
function handleEmptySelections(state: EditorState, dispatch: (transaction: Transaction) => void) {
const changes: ChangeSpec[] = [];
const newSelections: SelectionRange[] = [];
for (let range of state.selection.ranges) {
const line = state.doc.lineAt(range.head);
const beforeCursor = state.doc.sliceString(line.from, range.head);
if (/^\s*$/.test(beforeCursor)) {
// Only whitespace before cursor → indent line
return indentMore({ state, dispatch });
} else {
// Insert tab character at cursor
changes.push({ from: range.head, to: range.head, insert: "\t" });
newSelections.push(EditorSelection.cursor(range.head + 1));
}
}
if (changes.length) {
dispatch(
state.update({
changes,
selection: EditorSelection.create(newSelections),
scrollIntoView: true,
userEvent: "input"
})
);
return true;
}
return false;
}

View File

@ -1,5 +1,6 @@
import { EditorView, Decoration, MatchDecorator, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Range, RangeSet } from "@codemirror/state";
import { foldState, unfoldEffect } from "@codemirror/language";
import { Range, RangeSet, StateEffect } from "@codemirror/state";
const searchMatchDecoration = Decoration.mark({ class: "cm-searchMatch" });
const activeMatchDecoration = Decoration.mark({ class: "cm-activeMatch" });
@ -16,6 +17,7 @@ export class SearchHighlighter {
currentFound: number;
totalFound: number;
matcher?: MatchDecorator;
searchRegexp?: RegExp;
private parsedMatches: Match[];
constructor(public view: EditorView) {
@ -42,6 +44,7 @@ export class SearchHighlighter {
regexp: regex,
decoration: searchMatchDecoration,
});
this.searchRegexp = regex;
this.#updateSearchData(this.view);
this.#scrollToMatchNearestSelection();
}
@ -77,8 +80,23 @@ export class SearchHighlighter {
const match = this.parsedMatches[matchIndex];
this.currentFound = matchIndex + 1;
this.activeMatch = activeMatchDecoration.range(match.from, match.to);
// Check if the match is inside a folded region.
const unfoldEffects: StateEffect<unknown>[] = [];
const folded = this.view.state.field(foldState);
const iter = folded.iter();
while (iter.value) {
if (match.from >= iter.from && match.to <= iter.to) {
unfoldEffects.push(unfoldEffect.of({ from: iter.from, to: iter.to }));
}
iter.next();
}
this.view.dispatch({
effects: EditorView.scrollIntoView(match.from, { y: "center" }),
effects: [
...unfoldEffects,
EditorView.scrollIntoView(match.from, { y: "center" })
],
scrollIntoView: true
});
}
@ -98,17 +116,21 @@ export class SearchHighlighter {
return;
}
const matches = this.matcher.createDeco(view);
const cursor = matches.iter();
while (cursor.value) {
this.parsedMatches.push({
from: cursor.from,
to: cursor.to
});
cursor.next();
// Create the match decorator which will automatically highlight matches in the document.
this.matches = this.matcher.createDeco(view);
// Manually search for matches in the current document in order to get the total number of matches.
const parsedMatches: Match[] = [];
const text = view.state.doc.toString();
let match: RegExpExecArray | null | undefined;
while ((match = this.searchRegexp?.exec(text))) {
const from = match.index ?? 0;
const to = from + match[0].length;
parsedMatches.push({ from, to });
}
this.matches = matches;
this.parsedMatches = parsedMatches;
this.totalFound = this.parsedMatches.length;
}

View File

@ -1,6 +1,6 @@
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter } from "@codemirror/language";
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { highlightSelectionMatches } from "@codemirror/search";
import { vim } from "@replit/codemirror-vim";
@ -73,6 +73,7 @@ export default class CodeMirror extends EditorView {
]),
highlightSelectionMatches(),
bracketMatching(),
codeFolding(),
foldGutter(),
indentationMarkers(),
];

493
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff