Improve history view

This commit is contained in:
Andras Schmelczer 2025-03-22 18:41:30 +00:00
parent abe074202b
commit 30ecf52dde
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
7 changed files with 165 additions and 129 deletions

View file

@ -57,7 +57,6 @@ And to clean up the logs & database files, run `scripts/clean-up.sh`
## Todos ## Todos
- Don't show server traces on auth failure - Don't show server traces on auth failure
- better history tab
- Better server logs - Better server logs
- Allow setting config.yml path for server - Allow setting config.yml path for server
- history tab for going back - history tab for going back

View file

@ -164,6 +164,7 @@
.history-card-title { .history-card-title {
font: var(--font-monospace); font: var(--font-monospace);
display: flex; display: flex;
align-items: center;
gap: var(--size-4-2); gap: var(--size-4-2);
word-break: break-all; word-break: break-all;
margin: 0; margin: 0;

View file

@ -3,13 +3,19 @@ import { ItemView, setIcon } from "obsidian";
import { intlFormatDistance } from "date-fns"; import { intlFormatDistance } from "date-fns";
import type { HistoryEntry, SyncClient } from "sync-client"; import type { HistoryEntry, SyncClient } from "sync-client";
import { SyncType, SyncSource, SyncStatus } from "sync-client"; import { SyncType } from "sync-client";
export class HistoryView extends ItemView { export class HistoryView extends ItemView {
public static readonly TYPE = "history-view"; public static readonly TYPE = "history-view";
public static readonly ICON = "square-stack"; public static readonly ICON = "square-stack";
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private historyContainer: HTMLElement | undefined;
private readonly historyEntryToElement = new Map<
HistoryEntry,
HTMLElement
>();
public constructor( public constructor(
leaf: WorkspaceLeaf, leaf: WorkspaceLeaf,
private readonly client: SyncClient 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( private static renderSyncItemTitle(
element: HTMLElement, element: HTMLElement,
entry: HistoryEntry entry: HistoryEntry
@ -62,11 +56,6 @@ export class HistoryView extends ItemView {
element.createEl("span", { element.createEl("span", {
text: entry.relativePath text: entry.relativePath
}); });
const syncSourceIcon = HistoryView.getSyncSourceIcon(entry.source);
if (syncSourceIcon) {
setIcon(element.createDiv(), syncSourceIcon);
}
} }
public getViewType(): string { public getViewType(): string {
@ -78,6 +67,11 @@ export class HistoryView extends ItemView {
} }
public async onOpen(): Promise<void> { public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.createEl("h4", { text: "VaultLink history" });
this.historyContainer = container.createDiv({ cls: "logs-container" });
await this.updateView(); await this.updateView();
this.timer = setInterval(() => void this.updateView(), 1000); this.timer = setInterval(() => void this.updateView(), 1000);
} }
@ -89,66 +83,105 @@ export class HistoryView extends ItemView {
} }
private async updateView(): Promise<void> { private async updateView(): Promise<void> {
const container = this.containerEl.children[1]; const container = this.historyContainer;
container.empty(); if (container === undefined) {
container.createEl("h4", { text: "VaultLink History" }); return;
}
const entries = this.client.getHistoryEntries().reverse(); 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) => { entries.forEach((entry) => {
container.createDiv( const element = this.historyEntryToElement.get(entry);
{ if (element !== undefined) {
cls: ["history-card", entry.status.toLocaleLowerCase()] const timestampElement = element.querySelector(
}, ".history-card-timestamp"
(card) => { );
if ( if (timestampElement != null) {
this.app.vault.getFileByPath(entry.relativePath) !== timestampElement.textContent = intlFormatDistance(
null entry.timestamp,
) { new Date()
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"
});
} }
); 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"
});
}
);
} }
} }

View file

@ -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 { private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0]; return timestamp.toTimeString().split(" ")[0];
} }
@ -34,12 +54,12 @@ export class LogsView extends ItemView {
} }
public async onOpen(): Promise<void> { public async onOpen(): Promise<void> {
this.updateView();
const container = this.containerEl.children[1]; const container = this.containerEl.children[1];
container.addClass("logs-view"); container.addClass("logs-view");
container.createEl("h4", { text: "VaultLink logs" }); container.createEl("h4", { text: "VaultLink logs" });
this.logsContainer = container.createDiv({ cls: "logs-container" }); this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView();
} }
private updateView(): void { private updateView(): void {
@ -60,20 +80,7 @@ export class LogsView extends ItemView {
return; return;
} }
const element = container.createDiv( const element = LogsView.createLogLineElement(container, message);
{
cls: ["log-message", message.level]
},
(messageContainer) => {
messageContainer.createEl("span", {
text: LogsView.formatTimestamp(message.timestamp),
cls: "timestamp"
});
messageContainer.createEl("span", {
text: message.message
});
}
);
this.logLineToElement.set(message, element); this.logLineToElement.set(message, element);
}); });
@ -87,6 +94,7 @@ export class LogsView extends ItemView {
} }
if (logs.length === 0) { if (logs.length === 0) {
container.empty();
container.createEl("p", { container.createEl("p", {
text: "No logs available yet." text: "No logs available yet."
}); });

View file

@ -1,6 +1,5 @@
export { export {
SyncType, SyncType,
SyncSource,
SyncStatus, SyncStatus,
type HistoryStats, type HistoryStats,
type HistoryEntry type HistoryEntry

View file

@ -7,7 +7,7 @@ import type {
import type { SyncService } from "../services/sync-service"; import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { SyncHistory } from "../tracing/sync-history"; 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 { EMPTY_HASH, hash } from "../utils/hash";
import type { components } from "../services/types"; import type { components } from "../services/types";
import { deserialize } from "../utils/deserialize"; import { deserialize } from "../utils/deserialize";
@ -38,7 +38,6 @@ export class UnrestrictedSyncer {
return this.executeSync( return this.executeSync(
document.relativePath, document.relativePath,
SyncType.CREATE, SyncType.CREATE,
SyncSource.PUSH,
async () => { async () => {
const contentBytes = await this.operations.read( const contentBytes = await this.operations.read(
document.relativePath document.relativePath
@ -53,7 +52,6 @@ export class UnrestrictedSyncer {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
source: SyncSource.PUSH,
relativePath: document.relativePath, relativePath: document.relativePath,
message: `Successfully uploaded locally created file`, message: `Successfully uploaded locally created file`,
type: SyncType.CREATE type: SyncType.CREATE
@ -78,7 +76,6 @@ export class UnrestrictedSyncer {
await this.executeSync( await this.executeSync(
document.relativePath, document.relativePath,
SyncType.DELETE, SyncType.DELETE,
SyncSource.PUSH,
async () => { async () => {
const response = await this.syncService.delete({ const response = await this.syncService.delete({
documentId: document.documentId, documentId: document.documentId,
@ -87,7 +84,6 @@ export class UnrestrictedSyncer {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
source: SyncSource.PUSH,
relativePath: document.relativePath, relativePath: document.relativePath,
message: `Successfully deleted locally deleted file on the remote server`, message: `Successfully deleted locally deleted file on the remote server`,
type: SyncType.DELETE type: SyncType.DELETE
@ -118,7 +114,6 @@ export class UnrestrictedSyncer {
await this.executeSync( await this.executeSync(
document.relativePath, document.relativePath,
SyncType.UPDATE, SyncType.UPDATE,
SyncSource.PUSH,
async () => { async () => {
const originalRelativePath = document.relativePath; const originalRelativePath = document.relativePath;
@ -188,18 +183,18 @@ export class UnrestrictedSyncer {
return; return;
} }
this.history.addHistoryEntry({ if (!force) {
status: SyncStatus.SUCCESS, this.history.addHistoryEntry({
source: SyncSource.PUSH, status: SyncStatus.SUCCESS,
relativePath: document.relativePath, relativePath: document.relativePath,
message: `Successfully uploaded locally updated file to the remote server`, message: `Successfully uploaded locally updated file to the remote server`,
type: SyncType.UPDATE type: SyncType.UPDATE
}); });
}
if (response.isDeleted) { if (response.isDeleted) {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
source: SyncSource.PULL,
relativePath: document.relativePath, relativePath: document.relativePath,
message: message:
"The file we tried to update had been deleted remotely, therefore, we have deleted it locally", "The file we tried to update had been deleted remotely, therefore, we have deleted it locally",
@ -253,13 +248,14 @@ export class UnrestrictedSyncer {
responseBytes responseBytes
); );
this.history.addHistoryEntry({ if (!force) {
status: SyncStatus.SUCCESS, this.history.addHistoryEntry({
source: SyncSource.PULL, status: SyncStatus.SUCCESS,
relativePath: document.relativePath, relativePath: document.relativePath,
message: `The file we updated had been updated remotely, so we downloaded the merged version`, message: `The file we updated had been updated remotely, so we downloaded the merged version`,
type: SyncType.UPDATE type: SyncType.UPDATE
}); });
}
} }
this.tryIncrementVaultUpdateId(response.vaultUpdateId); this.tryIncrementVaultUpdateId(response.vaultUpdateId);
@ -274,7 +270,6 @@ export class UnrestrictedSyncer {
await this.executeSync( await this.executeSync(
remoteVersion.relativePath, remoteVersion.relativePath,
SyncType.UPDATE, SyncType.UPDATE,
SyncSource.PULL,
async () => { async () => {
if (document?.metadata !== undefined) { if (document?.metadata !== undefined) {
// If the file exists locally, let's pretend the user has updated it // If the file exists locally, let's pretend the user has updated it
@ -358,7 +353,6 @@ export class UnrestrictedSyncer {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
source: SyncSource.PULL,
relativePath: remoteVersion.relativePath, relativePath: remoteVersion.relativePath,
message: `Successfully downloaded remote file which hadn't existed locally`, message: `Successfully downloaded remote file which hadn't existed locally`,
type: SyncType.CREATE type: SyncType.CREATE
@ -370,12 +364,9 @@ export class UnrestrictedSyncer {
public async executeSync<T>( public async executeSync<T>(
relativePath: RelativePath, relativePath: RelativePath,
syncType: SyncType, syncType: SyncType,
syncSource: SyncSource,
fn: () => Promise<T> fn: () => Promise<T>
): Promise<T | undefined> { ): Promise<T | undefined> {
this.logger.debug( this.logger.debug(`Syncing ${relativePath} (${syncType})`);
`Syncing ${relativePath} (${syncSource} - ${syncType})`
);
try { try {
if ( if (
@ -401,7 +392,7 @@ export class UnrestrictedSyncer {
if (e instanceof FileNotFoundError) { if (e instanceof FileNotFoundError) {
// A subsequent sync operation must have been creating to deal with this // A subsequent sync operation must have been creating to deal with this
this.logger.info( 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; return;
} }
@ -414,9 +405,8 @@ export class UnrestrictedSyncer {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.ERROR, status: SyncStatus.ERROR,
relativePath, relativePath,
message: `Failed to ${syncSource.toLocaleLowerCase()} file because of ${e} when trying to ${syncType.toLocaleLowerCase()} it`, message: `Failed to sync file '${relativePath}' because of ${e} when trying to ${syncType.toLocaleLowerCase()} it`,
type: syncType, type: syncType
source: syncSource
}); });
throw e; throw e;
} }

View file

@ -6,7 +6,6 @@ export interface CommonHistoryEntry {
relativePath: RelativePath; relativePath: RelativePath;
message: string; message: string;
type?: SyncType; type?: SyncType;
source?: SyncSource;
} }
export enum SyncType { export enum SyncType {
@ -15,11 +14,6 @@ export enum SyncType {
DELETE = "DELETE" DELETE = "DELETE"
} }
export enum SyncSource {
PUSH = "PUSH",
PULL = "PULL"
}
export enum SyncStatus { export enum SyncStatus {
SUCCESS = "SUCCESS", SUCCESS = "SUCCESS",
ERROR = "ERROR" ERROR = "ERROR"
@ -35,7 +29,7 @@ export interface HistoryStats {
export class SyncHistory { export class SyncHistory {
private static readonly MAX_ENTRIES = 500; private static readonly MAX_ENTRIES = 500;
private readonly entries: HistoryEntry[] = []; private entries: HistoryEntry[] = [];
private readonly syncHistoryUpdateListeners: (( private readonly syncHistoryUpdateListeners: ((
status: HistoryStats status: HistoryStats
@ -75,6 +69,18 @@ export class SyncHistory {
...entry, ...entry,
timestamp: new Date() 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); this.entries.push(historyEntry);
if (entry.status === SyncStatus.SUCCESS) { if (entry.status === SyncStatus.SUCCESS) {