Add WebSocket support (#12)

This commit is contained in:
Andras Schmelczer 2025-03-29 10:17:46 +00:00 • committed by GitHub
commit 1aad0fce31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2581 additions and 996 deletions

View file

@ -1,58 +0,0 @@
import type { SyncClient } from "sync-client";
import type { TAbstractFile } from "obsidian";
import { TFile } from "obsidian";
export class ObsidianFileEventHandler {
public constructor(private readonly client: SyncClient) {}
public async onCreate(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File created: ${file.path}`);
await this.client.syncLocallyCreatedFile(file.path);
} else {
this.client.logger.debug(`Folder created: ${file.path}, ignored`);
}
}
public async onDelete(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File deleted: ${file.path}`);
await this.client.syncLocallyDeletedFile(file.path);
} else {
this.client.logger.debug(`Folder deleted: ${file.path}, ignored`);
}
}
public async onRename(file: TAbstractFile, oldPath: string): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File renamed: ${oldPath} -> ${file.path}`);
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
} else {
this.client.logger.debug(
`Folder renamed: ${oldPath} -> ${file.path}, ignored`
);
}
}
public async onModify(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
if (file.basename.startsWith("console-log.iPhone")) {
return;
}
this.client.logger.info(`File modified: ${file.path}`);
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
} else {
this.client.logger.debug(`Folder modified: ${file.path}, ignored`);
}
}
}

View file

@ -1,23 +1,38 @@
import type { Stat, Vault } from "obsidian";
import { normalizePath } from "obsidian";
import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import type { FileSystemOperations, RelativePath } from "sync-client";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(private readonly vault: Vault) {}
public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
public async listAllFiles(): Promise<RelativePath[]> {
return this.vault.getFiles().map((file) => file.path);
}
public async read(path: RelativePath): Promise<Uint8Array> {
return new Uint8Array(
await this.vault.adapter.readBinary(normalizePath(path))
);
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue());
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
view.editor.setValue(new TextDecoder().decode(content));
return;
}
return this.vault.adapter.writeBinary(
normalizePath(path),
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
);
@ -27,7 +42,16 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
path: RelativePath,
updater: (currentContent: string) => string
): Promise<string> {
return this.vault.adapter.process(normalizePath(path), updater);
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const result = updater(view.editor.getValue());
view.editor.setValue(result);
return result;
}
return this.vault.adapter.process(path, updater);
}
public async getFileSize(path: RelativePath): Promise<number> {

View file

@ -1,185 +0,0 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}
.logs-view {
display: flex;
flex-direction: column;
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-green-rgb);
}
&.WARNING {
color: var(--color-yellow-rgb);
}
&.ERROR {
color: var(--color-red-rgb);
}
}
}
}
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -1,20 +1,25 @@
import type { WorkspaceLeaf } from "obsidian";
import { Platform, Plugin } from "obsidian";
import "./styles.scss";
import type {
Editor,
MarkdownFileInfo,
MarkdownView,
TAbstractFile,
WorkspaceLeaf
} from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json";
import { SyncSettingsTab } from "./views/settings-tab";
import { HistoryView } from "./views/history-view";
import { ObsidianFileEventHandler } from "./obisidan-event-handler";
import { StatusBar } from "./views/status-bar";
import { LogsView } from "./views/logs-view";
import { StatusDescription } from "./views/status-description";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description";
import type { LogLine } from "sync-client";
import { SyncClient, LogLevel } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
export default class VaultLinkPlugin extends Plugin {
private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient;
private static registerConsoleForLogging(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
@ -38,7 +43,10 @@ export default class VaultLinkPlugin extends Plugin {
public async onload(): Promise<void> {
this.client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(this.app.vault),
fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
@ -80,35 +88,9 @@ export default class VaultLinkPlugin extends Plugin {
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
);
const eventHandler = new ObsidianFileEventHandler(this.client);
this.app.workspace.onLayoutReady(async () => {
this.client.logger.info("Initialising sync handlers");
[
this.app.vault.on(
"create",
eventHandler.onCreate.bind(eventHandler)
),
this.app.vault.on(
"modify",
eventHandler.onModify.bind(eventHandler)
),
this.app.vault.on(
"delete",
eventHandler.onDelete.bind(eventHandler)
),
this.app.vault.on(
"rename",
eventHandler.onRename.bind(eventHandler)
)
].forEach((event) => {
this.registerEvent(event);
});
this.registerEditorEvents();
void this.client.start();
this.client.logger.info("Sync handlers initialised");
});
}
@ -145,4 +127,51 @@ export default class VaultLinkPlugin extends Plugin {
await workspace.revealLeaf(leaf);
}
}
private registerEditorEvents(): void {
[
this.app.workspace.on(
"editor-change",
async (
_editor: Editor,
info: MarkdownView | MarkdownFileInfo
) => {
const { file } = info;
if (file) {
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
}
}
),
this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
}
}),
this.app.vault.on("delete", async (file: TAbstractFile) => {
await this.client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
}
}
)
].forEach((event) => {
this.registerEvent(event);
});
}
}

View file

@ -0,0 +1,53 @@
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -0,0 +1,198 @@
import "./history-view.scss";
import type { IconName, WorkspaceLeaf } from "obsidian";
import { ItemView, setIcon } from "obsidian";
import { intlFormatDistance } from "date-fns";
import type { HistoryEntry, SyncClient } 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
) {
super(leaf);
this.icon = HistoryView.ICON;
this.client.addSyncHistoryUpdateListener(
() =>
void this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
})
);
}
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
switch (type) {
case SyncType.CREATE:
return "file-plus";
case SyncType.DELETE:
return "trash-2";
case SyncType.UPDATE:
return "file-pen-line";
case undefined:
default:
return "";
}
}
private static renderSyncItemTitle(
element: HTMLElement,
entry: HistoryEntry
): void {
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.type);
if (syncTypeIcon) {
setIcon(element.createDiv(), syncTypeIcon);
}
element.createEl("span", {
text: entry.relativePath.split("/").pop()
});
}
private static updateTimeSince(
element: HTMLElement,
entry: HistoryEntry
): void {
const timestampElement = element.querySelector(
".history-card-timestamp"
);
if (timestampElement != null) {
timestampElement.textContent = intlFormatDistance(
entry.timestamp,
new Date()
);
}
}
public getViewType(): string {
return HistoryView.TYPE;
}
public getDisplayText(): string {
return "VaultLink history";
}
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();
this.timer = setInterval(() => void this.updateView(), 1000);
}
public async onClose(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
}
}
private async updateView(): Promise<void> {
const container = this.historyContainer;
if (container === undefined) {
return;
}
const entries = this.client.getHistoryEntries();
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
// Clear the "No update has happened yet" message
container.empty();
}
entries.forEach((entry) => {
const element = this.historyEntryToElement.get(entry);
if (element !== undefined) {
HistoryView.updateTimeSince(element, entry);
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

@ -0,0 +1,60 @@
.logs-view {
display: flex;
flex-direction: column;
.verbosity-selector {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2);
h4 {
margin: 0;
}
select {
cursor: pointer;
}
}
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-base-100);
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
}

View file

@ -0,0 +1,150 @@
import "./logs-view.scss";
import type { WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian";
import type { LogLine } from "sync-client";
import { LogLevel, type SyncClient } from "sync-client";
export class LogsView extends ItemView {
public static readonly TYPE = "logs-view";
public static readonly ICON = "logs";
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
private logsContainer: HTMLElement | undefined;
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO;
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = LogsView.ICON;
this.client.logger.addOnMessageListener(() => {
this.updateView();
});
}
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];
}
public getViewType(): string {
return LogsView.TYPE;
}
public getDisplayText(): string {
return "VaultLink logs";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.addClass("logs-view");
const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR }
];
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
verbositySection.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
this.logsContainer?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer = container.createDiv({ cls: "logs-container" });
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
return;
}
const logs = this.client.logger.getMessages(this.minLogLevel);
if (this.logLineToElement.size === 0 && logs.length > 0) {
// Clear the "No logs available yet" message
container.empty();
}
const shouldScroll =
container.scrollTop == 0 ||
container.scrollHeight -
container.clientHeight -
container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
logs.forEach((message) => {
if (this.logLineToElement.has(message)) {
return;
}
const element = LogsView.createLogLineElement(container, message);
this.logLineToElement.set(message, element);
});
const newLines = new Set(logs);
for (const [logLine, element] of this.logLineToElement) {
if (!newLines.has(logLine)) {
element.remove();
this.logLineToElement.delete(logLine);
}
}
if (logs.length === 0) {
container.empty();
container.createEl("p", {
text: "No logs available yet."
});
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
}

View file

@ -0,0 +1,57 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}

View file

@ -0,0 +1,340 @@
import "./settings-tab.scss";
import type { App } from "obsidian";
import { Notice, PluginSettingTab, Setting } from "obsidian";
import type VaultLinkPlugin from "src/vault-link-plugin";
import type { SyncClient, SyncSettings } from "sync-client";
import { HistoryView } from "../history/history-view";
import { LogsView } from "../logs/logs-view";
import type { StatusDescription } from "../status-description/status-description";
export class SyncSettingsTab extends PluginSettingTab {
private editedServerUri: string;
private editedToken: string;
private editedVaultName: string;
private readonly plugin: VaultLinkPlugin;
private readonly syncClient: SyncClient;
private readonly statusDescription: StatusDescription;
private statusDescriptionSubscription: (() => void) | undefined;
public constructor({
app,
plugin,
syncClient,
statusDescription
}: {
app: App;
plugin: VaultLinkPlugin;
syncClient: SyncClient;
statusDescription: StatusDescription;
}) {
super(app, plugin);
this.plugin = plugin;
this.syncClient = syncClient;
this.statusDescription = statusDescription;
this.editedServerUri = this.syncClient.getSettings().remoteUri;
this.editedToken = this.syncClient.getSettings().token;
this.editedVaultName = this.syncClient.getSettings().vaultName;
this.syncClient.addOnSettingsChangeListener(
(newSettings, oldSettings) => {
let hasChanged = false;
if (newSettings.remoteUri !== oldSettings.remoteUri) {
this.editedServerUri = newSettings.remoteUri;
hasChanged = true;
}
if (newSettings.token !== oldSettings.token) {
this.editedToken = newSettings.token;
hasChanged = true;
}
if (newSettings.vaultName !== oldSettings.vaultName) {
this.editedVaultName = newSettings.vaultName;
hasChanged = true;
}
if (hasChanged) {
this.display();
}
}
);
}
public display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("vault-link-settings");
this.renderSettingsHeader(containerEl);
this.renderConnectionSettings(containerEl);
this.renderSyncSettings(containerEl);
}
public hide(): void {
super.hide();
this.setStatusDescriptionSubscription();
}
private renderSettingsHeader(containerEl: HTMLElement): void {
containerEl.createEl("h2", { text: "VaultLink" }).createSpan({
text: this.plugin.manifest.version,
cls: "version"
});
containerEl.createDiv(
{
cls: "description"
},
(descriptionContainer) => {
this.setStatusDescriptionSubscription((): void => {
this.statusDescription.renderStatusDescription(
descriptionContainer
);
});
}
);
containerEl.createDiv(
{
cls: "button-container"
},
(buttonContainer) => {
buttonContainer.createEl(
"button",
{
text: "Show history"
},
(button) =>
(button.onclick = async (): Promise<void> => {
this.plugin.closeSettings();
await this.plugin.activateView(HistoryView.TYPE);
})
);
buttonContainer.createEl(
"button",
{
text: "Show logs"
},
(button) =>
(button.onclick = async (): Promise<void> => {
this.plugin.closeSettings();
await this.plugin.activateView(LogsView.TYPE);
})
);
}
);
}
private renderConnectionSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Connection" });
const [title, updateTitle] = this.unsavedAwareSettingName(
"Server address",
"remoteUri"
);
new Setting(containerEl)
.setName(title)
.setDesc(
"Your VaultLink server's URL including the protocol and full path."
)
.setTooltip("This is the URL of the server you want to sync with.")
.addText((text) =>
text
.setPlaceholder("https://example.com:3000")
.setValue(this.editedServerUri)
.onChange((value) => {
this.editedServerUri = value;
updateTitle(value);
})
);
const [tokenTitle, updateTokenTitle] = this.unsavedAwareSettingName(
"Access token",
"token"
);
new Setting(containerEl)
.setName(tokenTitle)
.setClass("sync-settings-access-token")
.setDesc(
"Set the access token for the server that you can get from the server"
)
.setTooltip("todo, links to dcocs")
.addTextArea((text) =>
text
.setPlaceholder("ey...")
.setValue(this.editedToken)
.onChange((value) => {
this.editedToken = value;
updateTokenTitle(value);
})
);
const [vaultNameTitle, updateVaultNameTitle] =
this.unsavedAwareSettingName("Vault name", "vaultName");
new Setting(containerEl)
.setName(vaultNameTitle)
.setDesc(
"Set the name of the remote vault that you want to sync with"
)
.setTooltip("todo, links to dcocs")
.addText((text) =>
text
.setPlaceholder("My Obsidian Vault")
.setValue(this.editedVaultName)
.onChange((value) => {
this.editedVaultName = value;
updateVaultNameTitle(value);
})
);
new Setting(containerEl)
.addButton((button) =>
button.setButtonText("Apply").onClick(async () => {
if (
this.editedVaultName !==
this.syncClient.getSettings().vaultName ||
this.editedServerUri !==
this.syncClient.getSettings().remoteUri ||
this.editedToken !== this.syncClient.getSettings().token
) {
await this.syncClient.setSettings({
vaultName: this.editedVaultName,
remoteUri: this.editedServerUri,
token: this.editedToken
});
new Notice(
"The changes have been applied successfully!"
);
await this.statusDescription.updateConnectionState();
} else {
new Notice("No changes to apply");
}
})
)
.addButton((button) =>
button.setButtonText("Test connection").onClick(async () => {
new Notice(
(await this.syncClient.checkConnection()).serverMessage
);
await this.statusDescription.updateConnectionState();
})
);
}
private renderSyncSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Sync" });
new Setting(containerEl)
.setName("Enable sync")
.setDesc(
"Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server."
)
.setTooltip(
"Enable pulling and pushing changes to the remote server."
)
.addToggle((toggle) =>
toggle
.setValue(this.syncClient.getSettings().isSyncEnabled)
.onChange(async (value) =>
this.syncClient.setSetting("isSyncEnabled", value)
)
);
new Setting(containerEl)
.setName("Sync concurrency")
.setDesc(
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
)
.addSlider((text) =>
text
.setLimits(1, 16, 1)
.setDynamicTooltip()
.setInstant(false)
.setValue(this.syncClient.getSettings().syncConcurrency)
.onChange(async (value) =>
this.syncClient.setSetting("syncConcurrency", value)
)
);
new Setting(containerEl)
.setName("Maximum file size to be uploaded (MB)")
.setDesc(
"Set the maximum file size that can be uploaded to the server. Files larger than this size will be ignored."
)
.addSlider((slider) =>
slider
.setLimits(1, 64, 1)
.setDynamicTooltip()
.setInstant(false)
.setValue(this.syncClient.getSettings().maxFileSizeMB)
.onChange(async (value) =>
this.syncClient.setSetting("maxFileSizeMB", value)
)
);
new Setting(containerEl)
.setName("Danger zone")
.setDesc(
"Delete the local metadata database while leaving the local and remote files intact."
)
.addButton((button) =>
button.setButtonText("Reset sync state").onClick(async () => {
await this.syncClient.reset();
new Notice(
"Sync state has been reset, you will need to resync"
);
})
);
}
private setStatusDescriptionSubscription(
newSubscription?: () => void
): void {
if (this.statusDescriptionSubscription) {
this.statusDescription.removeStatusChangeListener(
this.statusDescriptionSubscription
);
}
this.statusDescriptionSubscription = newSubscription;
if (this.statusDescriptionSubscription) {
this.statusDescriptionSubscription();
this.statusDescription.addStatusChangeListener(
this.statusDescriptionSubscription
);
}
}
private unsavedAwareSettingName(
name: string,
settingName: keyof SyncSettings
): [
DocumentFragment,
(newValue: SyncSettings[keyof SyncSettings]) => void
] {
const titleContainer = document.createDocumentFragment();
const title = titleContainer.createEl("div", {
text: name,
cls: "setting-item-name"
});
const updateTitle = (
currentValue: SyncSettings[keyof SyncSettings]
): void => {
title.innerText = `${name}${
currentValue !== this.syncClient.getSettings()[settingName]
? " (unsaved)"
: ""
}`;
};
return [titleContainer, updateTitle];
}
}

View file

@ -0,0 +1,14 @@
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}

View file

@ -0,0 +1,77 @@
import "./status-bar.scss";
import type { HistoryStats, SyncClient } from "sync-client";
import type VaultLinkPlugin from "../../vault-link-plugin";
export class StatusBar {
private readonly statusBarItem: HTMLElement;
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
public constructor(
private readonly plugin: VaultLinkPlugin,
private readonly syncClient: SyncClient
) {
this.statusBarItem = plugin.addStatusBarItem();
this.syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateStatus();
});
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateStatus();
}
);
this.syncClient.addOnSettingsChangeListener(() => {
this.updateStatus();
});
}
private updateStatus(): void {
this.statusBarItem.empty();
const container = this.statusBarItem.createDiv({
cls: ["sync-status"]
});
if (!this.syncClient.getSettings().isSyncEnabled) {
const button = container.createEl("button", {
text: "VaultLink is disabled, click to configure",
cls: "initialize-button"
});
button.onclick = (): void => {
this.plugin.openSettings();
};
return;
}
let hasShownMessage = false;
if ((this.lastRemaining ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({ text: `${this.lastRemaining} ⏳` });
}
if ((this.lastHistoryStats?.success ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0} ✅`
});
}
if ((this.lastHistoryStats?.error ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0} ❌`
});
}
if (!hasShownMessage) {
container.createSpan({ text: "VaultLink is idle" });
}
}
}

View file

@ -0,0 +1,32 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}

View file

@ -0,0 +1,148 @@
import "./status-description.scss";
import type {
HistoryStats,
NetworkConnectionStatus,
SyncClient
} from "sync-client";
export class StatusDescription {
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => void)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();
syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateDescription();
});
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateDescription();
}
);
this.syncClient.addWebSocketStatusChangeListener(
() => void this.updateConnectionState()
);
this.syncClient.addOnSettingsChangeListener(
() => void this.updateConnectionState()
);
}
public async updateConnectionState(): Promise<void> {
this.lastConnectionState = await this.syncClient.checkConnection();
this.updateDescription();
}
public addStatusChangeListener(listener: () => void): void {
this.statusChangeListeners.push(listener);
}
public removeStatusChangeListener(listener: () => void): void {
this.statusChangeListeners = this.statusChangeListeners.filter(
(l) => l !== listener
);
}
public renderStatusDescription(container: HTMLElement): void {
container.empty();
container.addClass("status-description");
if (this.lastConnectionState == undefined) {
container.createSpan({
text: "VaultLink is starting up…",
cls: "warning"
});
return;
}
if (!this.lastConnectionState.isSuccessful) {
container.createSpan({
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
cls: "error"
});
return;
}
if (!this.lastConnectionState.isWebSocketConnected) {
container.createSpan({
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
cls: "error"
});
return;
}
container.createSpan({ text: "VaultLink is connected to the server " });
container.createEl("a", {
text: this.syncClient.getSettings().remoteUri,
href: this.syncClient.getSettings().remoteUri
});
container.createSpan({
text: ` and has indexed approximately `
});
container.createSpan({
text: `${this.syncClient.documentCount}`,
cls: "number"
});
container.createSpan({
text: ` documents. `
});
if (
(this.lastRemaining ?? 0) === 0 &&
(this.lastHistoryStats?.success ?? 0) === 0 &&
(this.lastHistoryStats?.error ?? 0) === 0
) {
if (this.syncClient.getSettings().isSyncEnabled) {
container.createSpan({
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
});
} else {
container.createSpan({
text: "However, syncing is disabled right now.",
cls: "warning"
});
}
return;
}
container.createSpan({
text: "The plugin has "
});
container.createSpan({
text: `${this.lastRemaining ?? 0}`,
cls: "number"
});
container.createSpan({
text: " outstanding operations while having succeeded "
});
container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0}`,
cls: ["number", "good"]
});
container.createSpan({
text: " times and failed "
});
container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0}`,
cls: ["number", "bad"]
});
container.createSpan({
text: " times."
});
}
private updateDescription(): void {
this.statusChangeListeners.forEach((listener) => {
listener();
});
}
}

View file

@ -6,7 +6,12 @@
"strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"lib": ["DOM", "ESNext"]
"lib": [
"DOM",
"ESNext"
]
},
"exclude": ["./dist"]
}
"exclude": [
"./dist"
]
}

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@
"@types/jest": "^29.5.14",
"@types/node": "^22.13.10",
"jest": "^29.7.0",
"sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.2.6",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
@ -30,6 +31,6 @@
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
"sync_lib": "file:../../backend/sync_lib/pkg"
"ws": "^8.18.1"
}
}
}

View file

@ -1,65 +0,0 @@
import type { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database";
// Manages locks on documents to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class DocumentLocks {
private readonly locked = new Set<RelativePath>();
private readonly waiters = new Map<RelativePath, (() => void)[]>();
public constructor(private readonly logger: Logger) {}
public tryLockDocument(relativePath: RelativePath): boolean {
if (this.locked.has(relativePath)) {
return false;
}
this.locked.add(relativePath);
return true;
}
public async waitForDocumentLock(
relativePath: RelativePath
): Promise<void> {
if (this.tryLockDocument(relativePath)) {
return Promise.resolve();
}
this.logger.debug(`Waiting for lock on ${relativePath}`);
return new Promise((resolve) => {
let waiting = this.waiters.get(relativePath);
if (!waiting) {
waiting = [];
this.waiters.set(relativePath, waiting);
}
waiting.push(resolve);
});
}
public unlockDocument(relativePath: RelativePath): void {
if (!this.locked.has(relativePath)) {
throw new Error(
`Document ${relativePath} is not locked, cannot unlock`
);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = this.waiters.get(relativePath)?.shift();
if (nextWaiting) {
this.logger.debug(`Granted lock on ${relativePath}`);
nextWaiting();
} else {
this.locked.delete(relativePath);
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
}

View file

@ -1,7 +1,7 @@
import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { DocumentLocks } from "./document-locks";
import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error";
/**
@ -10,13 +10,13 @@ import { FileNotFoundError } from "./file-not-found-error";
* single request in-flight for any one file through the use of locks.
*/
export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: DocumentLocks;
private readonly locks: Locks<RelativePath>;
public constructor(
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new DocumentLocks(logger);
this.locks = new Locks(logger);
}
public async listAllFiles(): Promise<RelativePath[]> {
@ -117,7 +117,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
: [pathOrPaths];
await Promise.all(
paths.map(async (path) => this.locks.waitForDocumentLock(path))
paths.map(async (path) => this.locks.waitForLock(path))
);
try {
@ -125,7 +125,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
} finally {
await Promise.all(
paths.map((path) => {
this.locks.unlockDocument(path);
this.locks.unlock(path);
})
);
}

View file

@ -5,10 +5,10 @@ export {
type HistoryEntry
} from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger";
export type { CheckConnectionResult } from "./services/sync-service";
export { type SyncSettings } from "./persistence/settings";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { NetworkConnectionStatus } from "./sync-client";
export { SyncClient } from "./sync-client";

Some files were not shown because too many files have changed in this diff Show more