From bfff6ea7b8a6e207bbd856be15b0f7fbc258757f Mon Sep 17 00:00:00 2001 From: SeleiXi Date: Mon, 11 May 2026 19:27:59 +0800 Subject: [PATCH 1/2] feat: add document history search support --- src/lib | 2 +- .../DocumentHistory/DocumentHistoryModal.ts | 177 +++++++++++++++++- 2 files changed, 175 insertions(+), 4 deletions(-) diff --git a/src/lib b/src/lib index 9753055..91b5981 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 97530553a63dc206ea3fb7ef60721cabda6c74cc +Subproject commit 91b59812191dc8e190658b4110eedd4dca5e1803 diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 7e7560a..97a7236 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -71,6 +71,14 @@ export class DocumentHistoryModal extends Modal { diffNavContainer!: HTMLDivElement; diffNavIndicator!: HTMLSpanElement; + // Search state + searchKeyword = ""; + searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = []; + currentSearchIndex = -1; + searchResultIndicator!: HTMLSpanElement; + searchProgressIndicator!: HTMLSpanElement; + searchTimeout: number | null = null; + constructor( app: App, core: LiveSyncBaseCore, @@ -176,12 +184,20 @@ export class DocumentHistoryModal extends Modal { for (const v of diff) { const x1 = v[0]; const x2 = v[1]; + let text = escapeStringToHTML(x2); + if (this.searchKeyword) { + const regex = new RegExp( + `(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "gi" + ); + text = text.replace(regex, "$1"); + } if (x1 == DIFF_DELETE) { - result += "" + escapeStringToHTML(x2) + ""; + result += "" + text + ""; } else if (x1 == DIFF_EQUAL) { - result += "" + escapeStringToHTML(x2) + ""; + result += "" + text + ""; } else if (x1 == DIFF_INSERT) { - result += "" + escapeStringToHTML(x2) + ""; + result += "" + text + ""; } } result = result.replace(/\n/g, "
"); @@ -218,6 +234,12 @@ export class DocumentHistoryModal extends Modal { } } if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file"; + + if (this.searchKeyword && typeof result == "string" && !this.showDiff) { + const regex = new RegExp(`(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + result = result.replace(regex, "$1"); + } + this.contentView.innerHTML = (this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result; } @@ -225,6 +247,11 @@ export class DocumentHistoryModal extends Modal { this.resetDiffNavigation(); if (this.showDiff) { this.navigateDiff("next"); + } else if (this.searchKeyword) { + const firstMark = this.contentView.querySelector("mark"); + if (firstMark) { + firstMark.scrollIntoView({ behavior: "smooth", block: "center" }); + } } } @@ -281,12 +308,156 @@ export class DocumentHistoryModal extends Modal { } } + /** + * Search through the last 100 revisions for the given keyword. + */ + async performSearch(keyword: string) { + this.searchKeyword = keyword; + this.searchResults = []; + this.currentSearchIndex = -1; + + if (!keyword) { + this.searchResultIndicator.textContent = ""; + this.searchProgressIndicator.textContent = ""; + return; + } + + const db = this.core.localDatabase; + const limit = 100; + const totalRevs = this.revs_info.length; + const end = Math.min(totalRevs, limit); + + this.searchProgressIndicator.textContent = "Searching..."; + + const dmp = new diff_match_patch(); + + // 0 is the newest, higher index is older. + for (let i = 0; i < end; i++) { + const revInfo = this.revs_info[i]; + const rev = revInfo.rev; + + this.searchProgressIndicator.textContent = `Searching ${i + 1}/${end}...`; + + const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true); + if (doc === false) continue; + + const content = readDocument(doc); + if (typeof content !== "string") continue; + + const keywordLower = keyword.toLocaleLowerCase(); + + // Search in content + if (content.toLocaleLowerCase().includes(keywordLower)) { + this.searchResults.push({ rev, index: i, matchType: "Content" }); + this.updateSearchUI(); + continue; + } + + // Search in diff (from older version to this version) + // Older version is at i + 1 + if (i < totalRevs - 1) { + const olderRev = this.revs_info[i + 1].rev; + const olderDoc = await db.getDBEntry(this.file, { rev: olderRev }, false, false, true); + if (olderDoc !== false) { + const olderContent = readDocument(olderDoc); + if (typeof olderContent === "string") { + const diffs = dmp.diff_main(olderContent, content); + let foundInDiff = false; + for (const d of diffs) { + if ((d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) && + d[1].toLocaleLowerCase().includes(keywordLower)) { + foundInDiff = true; + break; + } + } + if (foundInDiff) { + this.searchResults.push({ rev, index: i, matchType: "Diff" }); + this.updateSearchUI(); + } + } + } + } + } + + this.searchProgressIndicator.textContent = "Done"; + this.updateSearchUI(); + } + + updateSearchUI() { + if (this.searchResults.length === 0) { + this.searchResultIndicator.textContent = this.searchKeyword ? "No matches found" : ""; + } else { + const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0; + this.searchResultIndicator.textContent = `${current}/${this.searchResults.length} matches`; + } + } + + navigateSearch(direction: "prev" | "next") { + if (this.searchResults.length === 0) return; + + if (direction === "next") { + this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length; + } else { + this.currentSearchIndex = + this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1; + } + + const match = this.searchResults[this.currentSearchIndex]; + this.range.value = `${this.revs_info.length - 1 - match.index}`; + void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); + this.updateSearchUI(); + + // If it's a diff match, make sure Highlight diff is on + if (match.matchType === "Diff" && !this.showDiff) { + // We could auto-enable it, but maybe just notify the user? + // For now, let's just let the user toggle it if they want to see the diff. + } + } + override onOpen() { const { contentEl } = this; this.titleEl.setText("Document History"); contentEl.empty(); this.fileInfo = contentEl.createDiv(""); this.fileInfo.addClass("op-info"); + + // Search Row + const searchRow = contentEl.createDiv(""); + searchRow.addClass("op-info"); + searchRow.addClass("search-row"); + searchRow.style.display = "flex"; + searchRow.style.gap = "5px"; + searchRow.style.alignItems = "center"; + searchRow.style.marginBottom = "10px"; + + const searchInput = searchRow.createEl("input", { type: "text", placeholder: "Search in history (last 100)..." }); + searchInput.style.flexGrow = "1"; + searchInput.addEventListener("input", () => { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.searchTimeout = window.setTimeout(() => { + void this.performSearch(searchInput.value); + }, 500); + }); + + searchRow.createEl("button", { text: "\u25B2" }, (e) => { + e.title = "Previous match"; + e.addEventListener("click", () => this.navigateSearch("prev")); + }); + searchRow.createEl("button", { text: "\u25BC" }, (e) => { + e.title = "Next match"; + e.addEventListener("click", () => this.navigateSearch("next")); + }); + + this.searchResultIndicator = searchRow.createEl("span", { text: "" }); + this.searchResultIndicator.style.fontSize = "0.8em"; + this.searchResultIndicator.style.minWidth = "80px"; + + this.searchProgressIndicator = searchRow.createEl("span", { text: "" }); + this.searchProgressIndicator.style.fontSize = "0.8em"; + this.searchProgressIndicator.style.color = "var(--text-muted)"; + const divView = contentEl.createDiv(""); divView.addClass("op-flex"); From 0d9397c8b9f91ac1a694d7f9d4bb90d72586e9b7 Mon Sep 17 00:00:00 2001 From: SeleiXi Date: Tue, 12 May 2026 00:52:20 +0800 Subject: [PATCH 2/2] fix: resolve UI alignment issue for diff navigation buttons --- .../DocumentHistory/DocumentHistoryModal.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 97a7236..742e873 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -473,8 +473,18 @@ export class DocumentHistoryModal extends Modal { const diffOptionsRow = contentEl.createDiv(""); diffOptionsRow.addClass("op-info"); diffOptionsRow.addClass("diff-options-row"); + diffOptionsRow.style.display = "flex"; + diffOptionsRow.style.justifyContent = "space-between"; + diffOptionsRow.style.alignItems = "center"; - diffOptionsRow.createEl("label", {}, (label) => { + const highlightDiffContainer = diffOptionsRow.createDiv(""); + highlightDiffContainer.style.display = "flex"; + highlightDiffContainer.style.alignItems = "center"; + + highlightDiffContainer.createEl("label", {}, (label) => { + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.gap = "4px"; label.appendChild( createEl("input", { type: "checkbox" }, (checkbox) => { if (this.showDiff) { @@ -495,6 +505,7 @@ export class DocumentHistoryModal extends Modal { this.diffNavContainer = diffOptionsRow.createDiv(""); this.diffNavContainer.addClass("diff-nav"); this.diffNavContainer.style.display = this.showDiff ? "flex" : "none"; + this.diffNavContainer.style.marginLeft = "auto"; this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => { e.addClass("diff-nav-btn");