diff --git a/README.md b/README.md index 4fb4b3b9..cad994b6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,6 @@ And to clean up the logs & database files, run `scripts/clean-up.sh` ## Todos - Don't show server traces on auth failure -- better history tab - Better server logs - Allow setting config.yml path for server - history tab for going back diff --git a/frontend/obsidian-plugin/src/styles.scss b/frontend/obsidian-plugin/src/styles.scss index c66c24a8..9ffc5caa 100644 --- a/frontend/obsidian-plugin/src/styles.scss +++ b/frontend/obsidian-plugin/src/styles.scss @@ -164,6 +164,7 @@ .history-card-title { font: var(--font-monospace); display: flex; + align-items: center; gap: var(--size-4-2); word-break: break-all; margin: 0; diff --git a/frontend/obsidian-plugin/src/views/history-view.ts b/frontend/obsidian-plugin/src/views/history-view.ts index a4c09b55..4b743a46 100644 --- a/frontend/obsidian-plugin/src/views/history-view.ts +++ b/frontend/obsidian-plugin/src/views/history-view.ts @@ -3,13 +3,19 @@ import { ItemView, setIcon } from "obsidian"; import { intlFormatDistance } from "date-fns"; import type { HistoryEntry, SyncClient } from "sync-client"; -import { SyncType, SyncSource, SyncStatus } from "sync-client"; +import { SyncType } from "sync-client"; export class HistoryView extends ItemView { public static readonly TYPE = "history-view"; public static readonly ICON = "square-stack"; private timer: NodeJS.Timeout | null = null; + private historyContainer: HTMLElement | undefined; + private readonly historyEntryToElement = new Map< + HistoryEntry, + HTMLElement + >(); + public constructor( leaf: WorkspaceLeaf, private readonly client: SyncClient @@ -38,18 +44,6 @@ export class HistoryView extends ItemView { } } - private static getSyncSourceIcon(source: SyncSource | undefined): IconName { - switch (source) { - case SyncSource.PUSH: - return "upload"; - case SyncSource.PULL: - return "download"; - case undefined: - default: - return ""; - } - } - private static renderSyncItemTitle( element: HTMLElement, entry: HistoryEntry @@ -62,11 +56,6 @@ export class HistoryView extends ItemView { element.createEl("span", { text: entry.relativePath }); - - const syncSourceIcon = HistoryView.getSyncSourceIcon(entry.source); - if (syncSourceIcon) { - setIcon(element.createDiv(), syncSourceIcon); - } } public getViewType(): string { @@ -78,6 +67,11 @@ export class HistoryView extends ItemView { } public async onOpen(): Promise { + const container = this.containerEl.children[1]; + container.createEl("h4", { text: "VaultLink history" }); + + this.historyContainer = container.createDiv({ cls: "logs-container" }); + await this.updateView(); this.timer = setInterval(() => void this.updateView(), 1000); } @@ -89,66 +83,105 @@ export class HistoryView extends ItemView { } private async updateView(): Promise { - const container = this.containerEl.children[1]; - container.empty(); - container.createEl("h4", { text: "VaultLink History" }); + const container = this.historyContainer; + if (container === undefined) { + return; + } const entries = this.client.getHistoryEntries().reverse(); + + if (this.historyEntryToElement.size === 0 && entries.length > 0) { + // Clear the "No update has happened yet" message + container.empty(); + } + entries.forEach((entry) => { - container.createDiv( - { - cls: ["history-card", entry.status.toLocaleLowerCase()] - }, - (card) => { - if ( - this.app.vault.getFileByPath(entry.relativePath) !== - null - ) { - card.addEventListener("click", () => { - void this.app.workspace.openLinkText( - entry.relativePath, - entry.relativePath, - false - ); - }); - - card.addClass("clickable"); - } - - card.createDiv( - { - cls: "history-card-header" - }, - (header) => { - header.createEl( - "h5", - { - cls: "history-card-title" - }, - (title) => { - HistoryView.renderSyncItemTitle( - title, - entry - ); - } - ); - - header.createSpan({ - text: intlFormatDistance( - entry.timestamp, - new Date() - ), - cls: "history-card-timestamp" - }); - } + const element = this.historyEntryToElement.get(entry); + if (element !== undefined) { + const timestampElement = element.querySelector( + ".history-card-timestamp" + ); + if (timestampElement != null) { + timestampElement.textContent = intlFormatDistance( + entry.timestamp, + new Date() ); - - card.createEl("p", { - text: `${entry.message}.`, - cls: "history-card-message" - }); } - ); + return; + } + + const newElement = this.createHistoryCard(container, entry); + container.prepend(newElement); + this.historyEntryToElement.set(entry, newElement); }); + + const newEntries = new Set(entries); + for (const [entry, element] of this.historyEntryToElement) { + if (!newEntries.has(entry)) { + element.remove(); + this.historyEntryToElement.delete(entry); + } + } + + if (entries.length === 0) { + container.empty(); + container.createEl("p", { + text: "No update has happened yet." + }); + } + } + + private createHistoryCard( + container: HTMLElement, + entry: HistoryEntry + ): HTMLElement { + return container.createDiv( + { + cls: ["history-card", entry.status.toLocaleLowerCase()] + }, + (card) => { + if (this.app.vault.getFileByPath(entry.relativePath) !== null) { + card.addEventListener("click", () => { + void this.app.workspace.openLinkText( + entry.relativePath, + entry.relativePath, + false + ); + }); + + card.addClass("clickable"); + } + + card.createDiv( + { + cls: "history-card-header" + }, + (header) => { + header.createEl( + "h5", + { + cls: "history-card-title" + }, + (title) => { + HistoryView.renderSyncItemTitle(title, entry); + } + ); + + header.createSpan({ + text: intlFormatDistance( + entry.timestamp, + new Date() + ), + cls: "history-card-timestamp" + }); + } + ); + + card.createEl("p", { + text: `${entry.message}.`, + cls: "history-card-message" + }); + } + ); } } diff --git a/frontend/obsidian-plugin/src/views/logs-view.ts b/frontend/obsidian-plugin/src/views/logs-view.ts index a7ea6228..2e3ea88d 100644 --- a/frontend/obsidian-plugin/src/views/logs-view.ts +++ b/frontend/obsidian-plugin/src/views/logs-view.ts @@ -21,6 +21,26 @@ export class LogsView extends ItemView { }); } + private static createLogLineElement( + container: HTMLElement, + logLine: LogLine + ): HTMLElement { + return container.createDiv( + { + cls: ["log-message", logLine.level] + }, + (messageContainer) => { + messageContainer.createEl("span", { + text: LogsView.formatTimestamp(logLine.timestamp), + cls: "timestamp" + }); + messageContainer.createEl("span", { + text: logLine.message + }); + } + ); + } + private static formatTimestamp(timestamp: Date): string { return timestamp.toTimeString().split(" ")[0]; } @@ -34,12 +54,12 @@ export class LogsView extends ItemView { } public async onOpen(): Promise { - this.updateView(); - const container = this.containerEl.children[1]; container.addClass("logs-view"); container.createEl("h4", { text: "VaultLink logs" }); this.logsContainer = container.createDiv({ cls: "logs-container" }); + + this.updateView(); } private updateView(): void { @@ -60,20 +80,7 @@ export class LogsView extends ItemView { return; } - const element = container.createDiv( - { - cls: ["log-message", message.level] - }, - (messageContainer) => { - messageContainer.createEl("span", { - text: LogsView.formatTimestamp(message.timestamp), - cls: "timestamp" - }); - messageContainer.createEl("span", { - text: message.message - }); - } - ); + const element = LogsView.createLogLineElement(container, message); this.logLineToElement.set(message, element); }); @@ -87,6 +94,7 @@ export class LogsView extends ItemView { } if (logs.length === 0) { + container.empty(); container.createEl("p", { text: "No logs available yet." }); diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 9308c063..cb8a38a2 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -1,6 +1,5 @@ export { SyncType, - SyncSource, SyncStatus, type HistoryStats, type HistoryEntry diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index e6426fa0..4df4ae03 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -7,7 +7,7 @@ import type { import type { SyncService } from "../services/sync-service"; import type { Logger } from "../tracing/logger"; import type { SyncHistory } from "../tracing/sync-history"; -import { SyncSource, SyncStatus, SyncType } from "../tracing/sync-history"; +import { SyncStatus, SyncType } from "../tracing/sync-history"; import { EMPTY_HASH, hash } from "../utils/hash"; import type { components } from "../services/types"; import { deserialize } from "../utils/deserialize"; @@ -38,7 +38,6 @@ export class UnrestrictedSyncer { return this.executeSync( document.relativePath, SyncType.CREATE, - SyncSource.PUSH, async () => { const contentBytes = await this.operations.read( document.relativePath @@ -53,7 +52,6 @@ export class UnrestrictedSyncer { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, - source: SyncSource.PUSH, relativePath: document.relativePath, message: `Successfully uploaded locally created file`, type: SyncType.CREATE @@ -78,7 +76,6 @@ export class UnrestrictedSyncer { await this.executeSync( document.relativePath, SyncType.DELETE, - SyncSource.PUSH, async () => { const response = await this.syncService.delete({ documentId: document.documentId, @@ -87,7 +84,6 @@ export class UnrestrictedSyncer { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, - source: SyncSource.PUSH, relativePath: document.relativePath, message: `Successfully deleted locally deleted file on the remote server`, type: SyncType.DELETE @@ -118,7 +114,6 @@ export class UnrestrictedSyncer { await this.executeSync( document.relativePath, SyncType.UPDATE, - SyncSource.PUSH, async () => { const originalRelativePath = document.relativePath; @@ -188,18 +183,18 @@ export class UnrestrictedSyncer { return; } - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - source: SyncSource.PUSH, - relativePath: document.relativePath, - message: `Successfully uploaded locally updated file to the remote server`, - type: SyncType.UPDATE - }); + if (!force) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + relativePath: document.relativePath, + message: `Successfully uploaded locally updated file to the remote server`, + type: SyncType.UPDATE + }); + } if (response.isDeleted) { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, - source: SyncSource.PULL, relativePath: document.relativePath, message: "The file we tried to update had been deleted remotely, therefore, we have deleted it locally", @@ -253,13 +248,14 @@ export class UnrestrictedSyncer { responseBytes ); - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - source: SyncSource.PULL, - relativePath: document.relativePath, - message: `The file we updated had been updated remotely, so we downloaded the merged version`, - type: SyncType.UPDATE - }); + if (!force) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + relativePath: document.relativePath, + message: `The file we updated had been updated remotely, so we downloaded the merged version`, + type: SyncType.UPDATE + }); + } } this.tryIncrementVaultUpdateId(response.vaultUpdateId); @@ -274,7 +270,6 @@ export class UnrestrictedSyncer { await this.executeSync( remoteVersion.relativePath, SyncType.UPDATE, - SyncSource.PULL, async () => { if (document?.metadata !== undefined) { // If the file exists locally, let's pretend the user has updated it @@ -358,7 +353,6 @@ export class UnrestrictedSyncer { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, - source: SyncSource.PULL, relativePath: remoteVersion.relativePath, message: `Successfully downloaded remote file which hadn't existed locally`, type: SyncType.CREATE @@ -370,12 +364,9 @@ export class UnrestrictedSyncer { public async executeSync( relativePath: RelativePath, syncType: SyncType, - syncSource: SyncSource, fn: () => Promise ): Promise { - this.logger.debug( - `Syncing ${relativePath} (${syncSource} - ${syncType})` - ); + this.logger.debug(`Syncing ${relativePath} (${syncType})`); try { if ( @@ -401,7 +392,7 @@ export class UnrestrictedSyncer { if (e instanceof FileNotFoundError) { // A subsequent sync operation must have been creating to deal with this this.logger.info( - `Skip ${syncSource.toLocaleLowerCase()} file because it no longer exists when trying to ${syncType.toLocaleLowerCase()} it` + `Skiping file '${relativePath}' because it no longer exists when trying to ${syncType.toLocaleLowerCase()} it` ); return; } @@ -414,9 +405,8 @@ export class UnrestrictedSyncer { this.history.addHistoryEntry({ status: SyncStatus.ERROR, relativePath, - message: `Failed to ${syncSource.toLocaleLowerCase()} file because of ${e} when trying to ${syncType.toLocaleLowerCase()} it`, - type: syncType, - source: syncSource + message: `Failed to sync file '${relativePath}' because of ${e} when trying to ${syncType.toLocaleLowerCase()} it`, + type: syncType }); throw e; } diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index 7d3b8f9a..d1c69577 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -6,7 +6,6 @@ export interface CommonHistoryEntry { relativePath: RelativePath; message: string; type?: SyncType; - source?: SyncSource; } export enum SyncType { @@ -15,11 +14,6 @@ export enum SyncType { DELETE = "DELETE" } -export enum SyncSource { - PUSH = "PUSH", - PULL = "PULL" -} - export enum SyncStatus { SUCCESS = "SUCCESS", ERROR = "ERROR" @@ -35,7 +29,7 @@ export interface HistoryStats { export class SyncHistory { private static readonly MAX_ENTRIES = 500; - private readonly entries: HistoryEntry[] = []; + private entries: HistoryEntry[] = []; private readonly syncHistoryUpdateListeners: (( status: HistoryStats @@ -75,6 +69,18 @@ export class SyncHistory { ...entry, timestamp: new Date() }; + + const candidate = this.entries.find( + (e) => e.relativePath === historyEntry.relativePath + ); + if ( + candidate !== undefined && + (this.entries.slice(-1)[0] === candidate || + candidate.timestamp.getTime() + 10 * 1000 > + historyEntry.timestamp.getTime()) + ) { + this.entries = this.entries.filter((e) => e !== candidate); + } this.entries.push(historyEntry); if (entry.status === SyncStatus.SUCCESS) {