diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 25714b52..efe18433 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -68,6 +68,11 @@ export class DocumentHistoryModal extends Modal { currentDeleted = false; initialRev?: string; + // Revision navigation state (◀/▶ beside the range slider) + revPrevBtn!: HTMLButtonElement; + revNextBtn!: HTMLButtonElement; + revNavIndicator!: HTMLSpanElement; + // Diff navigation state currentDiffIndex = -1; diffNavContainer!: HTMLDivElement; @@ -78,6 +83,8 @@ export class DocumentHistoryModal extends Modal { searchKeyword = ""; searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = []; currentSearchIndex = -1; + searchPrevBtn!: HTMLButtonElement; + searchNextBtn!: HTMLButtonElement; searchResultIndicator!: HTMLSpanElement; searchProgressIndicator!: HTMLSpanElement; searchTimeout: number | null = null; @@ -121,12 +128,14 @@ export class DocumentHistoryModal extends Modal { this.range.value = this.range.max; this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`); await this.loadRevs(initialRev); + this.updateRevisionNavUI(); } catch (ex) { if (isErrorOfMissingDoc(ex)) { this.range.max = "0"; this.range.value = ""; this.range.disabled = true; this.contentView.setText(`We don't have any history for this note.`); + this.updateRevisionNavUI(); } else { this.contentView.setText(`Error while loading file.`); Logger(ex, LOG_LEVEL_VERBOSE); @@ -144,6 +153,37 @@ export class DocumentHistoryModal extends Modal { const index = this.revs_info.length - 1 - (Number(this.range.value) || 0); const rev = this.revs_info[index]; await this.showExactRev(rev.rev); + this.updateRevisionNavUI(); + } + + navigateVersion(direction: "older" | "newer") { + const current = Number(this.range.value) || 0; + const max = Number(this.range.max) || 0; + + if (direction === "older" && current > 0) { + this.range.value = `${current - 1}`; + } else if (direction === "newer" && current < max) { + this.range.value = `${current + 1}`; + } else { + return; + } + + this.updateRevisionNavUI(); + void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); + } + + updateRevisionNavUI() { + if (!this.revNavIndicator) return; + + const total = this.revs_info.length; + const max = Number(this.range.max) || 0; + const current = Number(this.range.value) || 0; + + this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014"); + + const disabled = !!this.range.disabled || total <= 1; + this.revPrevBtn.disabled = disabled || current <= 0; + this.revNextBtn.disabled = disabled || current >= max; } BlobURLs = new Map(); @@ -387,6 +427,7 @@ export class DocumentHistoryModal extends Modal { if (!keyword) { this.searchResultIndicator.setText(""); this.searchProgressIndicator.setText(""); + this.updateSearchUI(); return; } @@ -460,6 +501,10 @@ export class DocumentHistoryModal extends Modal { const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0; this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`); } + + const hasResults = this.searchResults.length > 0; + this.searchPrevBtn.disabled = !hasResults; + this.searchNextBtn.disabled = !hasResults; } navigateSearch(direction: "prev" | "next") { @@ -511,12 +556,14 @@ export class DocumentHistoryModal extends Modal { }, 500); }); - searchRow.createEl("button", { text: "\u25B2" }, (e) => { + this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => { e.title = "Previous match"; + e.disabled = true; e.addEventListener("click", () => this.navigateSearch("prev")); }); - searchRow.createEl("button", { text: "\u25BC" }, (e) => { + this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => { e.title = "Next match"; + e.disabled = true; e.addEventListener("click", () => this.navigateSearch("next")); }); @@ -526,18 +573,37 @@ export class DocumentHistoryModal extends Modal { this.searchProgressIndicator = searchRow.createEl("span", { text: "" }); this.searchProgressIndicator.addClass("history-search-progress-indicator"); - const divView = contentEl.createDiv(""); - divView.addClass("op-flex"); + const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" }); - divView.createEl("input", { type: "range" }, (e) => { + this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => { + e.addClass("history-rev-nav-btn"); + e.title = "Older revision"; + e.disabled = true; + e.addEventListener("click", () => this.navigateVersion("older")); + }); + + revNavRow.createEl("input", { type: "range" }, (e) => { this.range = e; - e.addEventListener("change", (e) => { + e.addEventListener("change", () => { + this.updateRevisionNavUI(); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); - e.addEventListener("input", (e) => { + e.addEventListener("input", () => { + this.updateRevisionNavUI(); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); }); + + this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => { + e.addClass("history-rev-nav-btn"); + e.title = "Newer revision"; + e.disabled = true; + e.addEventListener("click", () => this.navigateVersion("newer")); + }); + + this.revNavIndicator = revNavRow.createEl("span", { text: "\u2014" }, (e) => { + e.addClass("history-rev-indicator"); + }); const diffOptionsRow = contentEl.createDiv(""); diffOptionsRow.addClass("op-info"); diffOptionsRow.addClass("diff-options-row"); diff --git a/styles.css b/styles.css index e478615e..84b222c2 100644 --- a/styles.css +++ b/styles.css @@ -551,6 +551,51 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { color: var(--text-muted); } +.history-search-row button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-nav-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.history-rev-nav-row input[type="range"] { + flex-grow: 1; + margin-bottom: 0; +} + +.history-rev-nav-btn { + flex-shrink: 0; + padding: 2px 10px; + font-size: 0.9em; + cursor: pointer; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-secondary); + color: var(--text-normal); +} + +.history-rev-nav-btn:hover:not(:disabled) { + background-color: var(--background-modifier-hover); +} + +.history-rev-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-indicator { + flex-shrink: 0; + font-size: 0.85em; + color: var(--text-muted); + min-width: 4.5em; + text-align: right; +} + .history-diff-options-row { justify-content: space-between; } diff --git a/test/e2e-obsidian/scripts/document-history-nav.ts b/test/e2e-obsidian/scripts/document-history-nav.ts new file mode 100644 index 00000000..680fcca0 --- /dev/null +++ b/test/e2e-obsidian/scripts/document-history-nav.ts @@ -0,0 +1,269 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000"; + +const notePath = "E2E/document-history-nav.md"; +const revisions = ["Version one alpha", "Version two beta keyword", "Version three gamma"]; + +type RevisionInfo = { + revCount: number; + id: string; +}; + +type OpenHistoryResult = { + opened: boolean; + modalTitle: string | null; +}; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function assertTrue(value: boolean, message: string): void { + if (!value) { + throw new Error(message); + } +} + +async function dismissWelcomeWizard(port: number): Promise { + await withObsidianPage(port, async (page) => { + const cancel = page.getByText("No, please take me back"); + if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) { + await cancel.click(); + await page.waitForTimeout(500); + } + }); +} + +async function configureMinimalLocalMode(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ isConfigured: boolean }>( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyExternalSettings({", + "liveSync:false,", + "syncOnStart:false,", + "syncOnSave:false,", + "isConfigured:true,", + "doctorProcessedVersion:'0.25.27',", + "},true);", + "await core.services.control.applySettings();", + "const current=core.services.setting.currentSettings();", + "return JSON.stringify({isConfigured:current.isConfigured});", + "})()", + ].join(""), + env + ); +} + +async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const revisions=${JSON.stringify(revisions)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.delete(existing);", + "const id=await core.services.path.path2id(path);", + "let baseRev='';", + "for(const content of revisions){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now();", + " const result=await core.localDatabase.putDBEntry({", + " _id:id,", + " path,", + " data:blob,", + " ctime:now,", + " mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,", + " children:[],", + " datatype:'plain',", + " type:'plain',", + " eden:{},", + " },false,baseRev||undefined);", + " if(!result?.ok) throw new Error(`Could not store revision for ${path}`);", + " baseRev=result.rev;", + " await sleep(100);", + "}", + `await app.vault.create(path,revisions[revisions.length-1]);`, + "await core.services.fileProcessing.commitPendingFileEvents();", + "const raw=await core.localDatabase.getRaw(id,{revs_info:true});", + "const revCount=(raw._revs_info||[]).filter((e)=>e&&e.status==='available').length;", + "return JSON.stringify({revCount,id});", + "})()", + ].join(""), + env + ); +} + +async function openDocumentHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error('Note missing before opening history');", + "document.querySelectorAll('.modal-close-button').forEach((btn)=>btn.click());", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "const leaf=app.workspace.getLeaf(false);", + "await leaf.openFile(file);", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "await app.commands.executeCommandById('obsidian-livesync:livesync-history');", + "await new Promise((resolve)=>setTimeout(resolve,500));", + "const modal=document.querySelector('.modal-container .modal-title');", + "return JSON.stringify({opened:!!modal,modalTitle:modal?modal.textContent:null});", + "})()", + ].join(""), + env + ); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + const port = obsidianRemoteDebuggingPort(); + + const screenshotDir = process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ?? "/opt/cursor/artifacts/screenshots/document-history-nav"; + const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt"); + + async function captureStep(page: import("playwright").Page, step: string): Promise { + await mkdir(screenshotDir, { recursive: true }); + const path = join(screenshotDir, `${step}.png`); + await page.screenshot({ path, fullPage: true }); + console.log(`Screenshot: ${path}`); + return path; + } + + try { + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault: ${vault.path}`); + + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + await dismissWelcomeWizard(port); + await configureMinimalLocalMode(cli.binary, session.cliEnv); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + const revisionInfo = await seedRevisions(cli.binary, session.cliEnv); + console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`); + assertTrue(revisionInfo.revCount >= 2, `Expected at least 2 revisions, got ${revisionInfo.revCount}`); + + const opened = await openDocumentHistory(cli.binary, session.cliEnv); + assertEqual(opened.opened, true, "Document History modal did not open."); + assertEqual(opened.modalTitle, "Document History", "Unexpected modal title."); + + const report = await withObsidianPage(port, async (page) => { + const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); + await modal.waitFor({ state: "visible", timeout: 10000 }); + + const revNavRow = modal.locator(".history-rev-nav-row"); + await revNavRow.waitFor({ state: "visible", timeout: 10000 }); + + const indicator = revNavRow.locator(".history-rev-indicator"); + const initialIndicator = (await indicator.innerText()).trim(); + + const prevBtn = revNavRow.locator(".history-rev-nav-btn").first(); + const nextBtn = revNavRow.locator(".history-rev-nav-btn").last(); + const range = revNavRow.locator('input[type="range"]'); + + assertTrue(/Rev \d+\/\d+/.test(initialIndicator), `Unexpected initial indicator: ${initialIndicator}`); + + const initialRange = await range.inputValue(); + + const screenshotPaths: string[] = []; + screenshotPaths.push(await captureStep(page, "01-initial-latest-rev")); + + await prevBtn.click(); + await page.waitForTimeout(1000); + const afterPrevIndicator = (await indicator.innerText()).trim(); + const afterPrevRange = await range.inputValue(); + assertTrue( + Number(afterPrevRange) < Number(initialRange), + `◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}` + ); + assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator."); + screenshotPaths.push(await captureStep(page, "02-after-click-older-rev")); + + await nextBtn.click(); + await page.waitForTimeout(1000); + const afterNextIndicator = (await indicator.innerText()).trim(); + const afterNextRange = await range.inputValue(); + assertEqual(afterNextRange, initialRange, "▶ did not return to the original revision."); + assertEqual(afterNextIndicator, initialIndicator, "▶ did not restore the Rev indicator."); + screenshotPaths.push(await captureStep(page, "03-after-click-newer-rev")); + + const searchInput = modal.locator(".history-search-input"); + await searchInput.fill("keyword"); + await page.waitForTimeout(1500); + + const searchIndicator = modal.locator(".history-search-result-indicator"); + const searchText = (await searchIndicator.innerText()).trim(); + assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`); + screenshotPaths.push(await captureStep(page, "04-after-search-keyword")); + + const searchPrev = modal.locator(".history-search-row button").nth(0); + const searchNext = modal.locator(".history-search-row button").nth(1); + assertTrue(!(await searchPrev.isDisabled()), "Search ▲ should be enabled when matches exist."); + assertTrue(!(await searchNext.isDisabled()), "Search ▼ should be enabled when matches exist."); + + await searchNext.click(); + await page.waitForTimeout(1000); + + const afterSearchIndicator = (await searchIndicator.innerText()).trim(); + assertTrue(/1\/\d+ matches/.test(afterSearchIndicator), `Search navigation failed: ${afterSearchIndicator}`); + screenshotPaths.push(await captureStep(page, "05-after-search-next-match")); + + return [ + `initialIndicator: ${initialIndicator}`, + `afterPrevIndicator: ${afterPrevIndicator}`, + `afterNextIndicator: ${afterNextIndicator}`, + `searchIndicator: ${searchText}`, + `afterSearchIndicator: ${afterSearchIndicator}`, + "", + "Screenshots:", + ...screenshotPaths.map((p) => `- ${p}`), + ].join("\n"); + }); + + await writeFile(reportPath, report, "utf-8"); + console.log(`Document History UI test passed.`); + console.log(`Report: ${reportPath}`); + console.log(`Screenshots: ${screenshotDir}`); + } finally { + if (session) await session.app.stop(); + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); +