Apply editorconfig

This commit is contained in:
Andras Schmelczer 2025-12-07 13:38:23 +00:00
parent ad3191957a
commit b05e415acf
131 changed files with 16404 additions and 13617 deletions

View file

@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import type { CursorPosition, TextWithCursors } from "sync-client";
import {
utils,
type FileSystemOperations,
type RelativePath
utils,
type FileSystemOperations,
type RelativePath
} from "sync-client";
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = [];
const remainingFolders = [root ?? this.vault.getRoot().path];
public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = [];
const remainingFolders = [root ?? this.vault.getRoot().path];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const folder = remainingFolders.pop();
if (folder == undefined) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const folder = remainingFolders.pop();
if (folder == undefined) {
break;
}
// This would be a very bad idea to sync as it would mess with
// the integrity of the sync database.
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
continue;
}
// This would be a very bad idea to sync as it would mess with
// the integrity of the sync database.
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
continue;
}
const files = await this.vault.adapter.list(normalizePath(folder));
allFiles.push(...files.files);
remainingFolders.push(...files.folders);
}
const files = await this.vault.adapter.list(normalizePath(folder));
allFiles.push(...files.files);
remainingFolders.push(...files.folders);
}
return allFiles;
}
return allFiles;
}
public async read(path: RelativePath): Promise<Uint8Array> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue());
}
public async read(path: RelativePath): Promise<Uint8Array> {
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));
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const position = view.editor.getCursor();
view.editor.setValue(new TextDecoder().decode(content));
view.editor.setCursor(position);
return;
}
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const position = view.editor.getCursor();
view.editor.setValue(new TextDecoder().decode(content));
view.editor.setCursor(position);
return;
}
return this.vault.adapter.writeBinary(
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
);
}
return this.vault.adapter.writeBinary(
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
path = normalizePath(path);
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const text = view.editor.getValue();
if (view?.file?.path === path) {
const text = view.editor.getValue();
const cursors: CursorPosition[] = getSelectionsFromEditor(
view.editor
).flatMap(({ id, start: anchor, end: head }) => [
{
id: 2 * id,
position: anchor
},
{
id: 2 * id + 1,
position: head
}
]);
const cursors: CursorPosition[] = getSelectionsFromEditor(
view.editor
).flatMap(({ id, start: anchor, end: head }) => [
{
id: 2 * id,
position: anchor
},
{
id: 2 * id + 1,
position: head
}
]);
const result = updater({
text,
cursors
});
const result = updater({
text,
cursors
});
if (result.text === text) {
return text;
}
if (result.text === text) {
return text;
}
view.editor.setValue(result.text);
view.editor.setValue(result.text);
const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) {
const from = result.cursors[2 * i];
const to = result.cursors[2 * i + 1];
const { line: fromLine, column: fromColumn } =
utils.positionToLineAndColumn(result.text, from.position);
const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) {
const from = result.cursors[2 * i];
const to = result.cursors[2 * i + 1];
const { line: fromLine, column: fromColumn } =
utils.positionToLineAndColumn(result.text, from.position);
const { line: toLine, column: toColumn } =
utils.positionToLineAndColumn(result.text, to.position);
const { line: toLine, column: toColumn } =
utils.positionToLineAndColumn(result.text, to.position);
selections.push({
anchor: { line: fromLine, ch: fromColumn },
head: { line: toLine, ch: toColumn }
});
}
view.editor.setSelections(selections);
selections.push({
anchor: { line: fromLine, ch: fromColumn },
head: { line: toLine, ch: toColumn }
});
}
view.editor.setSelections(selections);
return result.text;
}
return result.text;
}
return this.vault.adapter.process(
path,
(text) =>
updater({
text,
cursors: []
}).text
);
}
return this.vault.adapter.process(
path,
(text) =>
updater({
text,
cursors: []
}).text
);
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size;
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size;
}
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path));
}
public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path));
}
public async delete(path: RelativePath): Promise<void> {
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path));
}
}
public async delete(path: RelativePath): Promise<void> {
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path));
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
}
return file;
}
}

View file

@ -12,4 +12,4 @@
font-size: var(--font-smallest);
font-style: italic;
}
}
}

View file

@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
import { utils } from "sync-client";
export interface Selection {
id: number;
start: number;
end: number;
id: number;
start: number;
end: number;
}
export function getSelectionsFromEditor(editor: Editor): Selection[] {
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
}));
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
}));
}

View file

@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
import { getSelectionsFromEditor } from "./get-selections-from-editor";
export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
private updateAllSelections(): void {
const currentCursors = this.getAllSelections();
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private updateAllSelections(): void {
const currentCursors = this.getAllSelections();
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getSelectionsFromEditor(view.editor);
});
return cursors;
}
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getSelectionsFromEditor(view.editor);
});
return cursors;
}
}

View file

@ -4,60 +4,60 @@ const CARET_WIDTH = 2;
const DOT_RADIUS = 4;
export const remoteCursorsTheme = EditorView.baseTheme({
".selection-caret": {
position: "relative"
},
".selection-caret": {
position: "relative"
},
".selection-caret > *": {
position: "absolute",
backgroundColor: "inherit"
},
".selection-caret > *": {
position: "absolute",
backgroundColor: "inherit"
},
".selection-caret > .stick": {
left: 0,
top: 0,
transform: "translateX(-50%)",
width: `${CARET_WIDTH}px`,
height: "100%",
display: "block",
borderRadius: `${CARET_WIDTH / 2}px`,
animation: "blink-stick 1s steps(1) infinite"
},
".selection-caret > .stick": {
left: 0,
top: 0,
transform: "translateX(-50%)",
width: `${CARET_WIDTH}px`,
height: "100%",
display: "block",
borderRadius: `${CARET_WIDTH / 2}px`,
animation: "blink-stick 1s steps(1) infinite"
},
"@keyframes blink-stick": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0 }
},
"@keyframes blink-stick": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0 }
},
".selection-caret > .dot": {
borderRadius: "50%",
width: `${DOT_RADIUS * 2}px`,
height: `${DOT_RADIUS * 2}px`,
top: `-${DOT_RADIUS}px`,
left: `-${DOT_RADIUS}px`,
transition: "transform .3s ease-in-out",
transformOrigin: "bottom center",
boxSizing: "border-box"
},
".selection-caret > .dot": {
borderRadius: "50%",
width: `${DOT_RADIUS * 2}px`,
height: `${DOT_RADIUS * 2}px`,
top: `-${DOT_RADIUS}px`,
left: `-${DOT_RADIUS}px`,
transition: "transform .3s ease-in-out",
transformOrigin: "bottom center",
boxSizing: "border-box"
},
".selection-caret:hover > .dot": {
transform: "scale(0)"
},
".selection-caret:hover > .dot": {
transform: "scale(0)"
},
".selection-caret > .info": {
top: "-1.3em",
left: `-${CARET_WIDTH / 2}px`,
fontSize: "0.9em",
userSelect: "none",
color: "white",
padding: "0 2px",
transition: "opacity .3s ease-in-out",
opacity: 0,
whiteSpace: "nowrap",
borderRadius: "3px 3px 3px 0"
},
".selection-caret > .info": {
top: "-1.3em",
left: `-${CARET_WIDTH / 2}px`,
fontSize: "0.9em",
userSelect: "none",
color: "white",
padding: "0 2px",
transition: "opacity .3s ease-in-out",
opacity: 0,
whiteSpace: "nowrap",
borderRadius: "3px 3px 3px 0"
},
".selection-caret:hover > .info": {
opacity: 1
}
".selection-caret:hover > .info": {
opacity: 1
}
});

View file

@ -1,46 +1,46 @@
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
import {
ViewUpdate,
ViewPlugin,
Decoration,
WidgetType
ViewUpdate,
ViewPlugin,
Decoration,
WidgetType
} from "@codemirror/view";
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
export class RemoteCursorWidget extends WidgetType {
public constructor(
private readonly color: string,
private readonly name: string
) {
super();
}
public constructor(
private readonly color: string,
private readonly name: string
) {
super();
}
public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl(
"span",
{
cls: "selection-caret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.createEl("div", {
cls: "stick"
});
span.createEl("div", {
cls: "dot"
});
span.createEl("div", {
cls: "info",
text: this.name
});
}
);
}
public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl(
"span",
{
cls: "selection-caret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.createEl("div", {
cls: "stick"
});
span.createEl("div", {
cls: "dot"
});
span.createEl("div", {
cls: "info",
text: this.name
});
}
);
}
public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name;
}
public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name;
}
}

View file

@ -3,17 +3,17 @@ import { RangeSet } from "@codemirror/state";
import { ViewPlugin, Decoration } from "@codemirror/view";
import type {
PluginValue,
DecorationSet,
EditorView,
ViewUpdate
PluginValue,
DecorationSet,
EditorView,
ViewUpdate
} from "@codemirror/view";
import { RemoteCursorWidget } from "./remote-cursor-widget";
import type { RelativePath } from "sync-client";
import {
utils,
type CursorSpan,
type MaybeOutdatedClientCursors
utils,
type CursorSpan,
type MaybeOutdatedClientCursors
} from "sync-client";
import type { App } from "obsidian";
import { MarkdownView } from "obsidian";
@ -25,241 +25,241 @@ import { reconcileWithHistory } from "reconcile-text";
const forceUpdate = StateEffect.define();
export class RemoteCursorsPluginValue implements PluginValue {
private static cursors: {
name: string;
path: string;
span: CursorSpan;
deviceId: string;
isOutdated: boolean;
}[] = [];
private static cursors: {
name: string;
path: string;
span: CursorSpan;
deviceId: string;
isOutdated: boolean;
}[] = [];
private static app?: App;
public decorations: DecorationSet = RangeSet.of([]);
private static app?: App;
public decorations: DecorationSet = RangeSet.of([]);
public static setCursors(
clients: MaybeOutdatedClientCursors[],
app: App
): void {
RemoteCursorsPluginValue.app = app;
RemoteCursorsPluginValue.cursors = [
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
clients.some(
(client) =>
client.deviceId === deviceId && client.isOutdated
)
),
...clients
.filter(
({ isOutdated, deviceId }) =>
!isOutdated ||
RemoteCursorsPluginValue.cursors.every(
(c) => deviceId !== c.deviceId
)
)
.flatMap((client) => {
const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...span }
}))
);
})
];
public static setCursors(
clients: MaybeOutdatedClientCursors[],
app: App
): void {
RemoteCursorsPluginValue.app = app;
RemoteCursorsPluginValue.cursors = [
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
clients.some(
(client) =>
client.deviceId === deviceId && client.isOutdated
)
),
...clients
.filter(
({ isOutdated, deviceId }) =>
!isOutdated ||
RemoteCursorsPluginValue.cursors.every(
(c) => deviceId !== c.deviceId
)
)
.flatMap((client) => {
const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...span }
}))
);
})
];
app.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView;
app.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView;
editor.dispatch({
effects: [forceUpdate.of(null)]
});
});
}
editor.dispatch({
effects: [forceUpdate.of(null)]
});
});
}
private static findFileForEditor(
editor: EditorView
): RelativePath | undefined {
return RemoteCursorsPluginValue.app?.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.flatMap((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((view.editor.cm as EditorView) !== editor) {
return [];
}
private static findFileForEditor(
editor: EditorView
): RelativePath | undefined {
return RemoteCursorsPluginValue.app?.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.flatMap((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((view.editor.cm as EditorView) !== editor) {
return [];
}
const { file } = view;
if (!file) {
return;
}
const { file } = view;
if (!file) {
return;
}
return [file.path];
})
.first();
}
return [file.path];
})
.first();
}
private static interpolateRemoteCursorPositions(
original: string,
edited: string
): void {
if (
original === edited ||
RemoteCursorsPluginValue.cursors.length === 0
) {
return;
}
private static interpolateRemoteCursorPositions(
original: string,
edited: string
): void {
if (
original === edited ||
RemoteCursorsPluginValue.cursors.length === 0
) {
return;
}
const updatedPositions: number[] = [];
const reconciled = reconcileWithHistory(
original,
{
text: original,
cursors: RemoteCursorsPluginValue.cursors.flatMap(
({ span }, i) => [
{ id: i * 2, position: span.start },
{ id: i * 2 + 1, position: span.end }
]
)
},
edited
);
const updatedPositions: number[] = [];
const reconciled = reconcileWithHistory(
original,
{
text: original,
cursors: RemoteCursorsPluginValue.cursors.flatMap(
({ span }, i) => [
{ id: i * 2, position: span.start },
{ id: i * 2 + 1, position: span.end }
]
)
},
edited
);
reconciled.cursors.forEach(({ id, position }) => {
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
position,
reconciled.history
);
if (whereToJump !== null) {
updatedPositions[id] = whereToJump;
} else {
updatedPositions[id] = position;
}
});
reconciled.cursors.forEach(({ id, position }) => {
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
position,
reconciled.history
);
if (whereToJump !== null) {
updatedPositions[id] = whereToJump;
} else {
updatedPositions[id] = position;
}
});
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
span.start = updatedPositions[i * 2];
span.end = updatedPositions[i * 2 + 1];
});
}
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
span.start = updatedPositions[i * 2];
span.end = updatedPositions[i * 2 + 1];
});
}
private static findWhereToMoveCursor(
cursor: number,
spans: SpanWithHistory[]
): number | null {
let position = 0;
for (const span of spans) {
// left and origin are the same
if (position === cursor && span.history === "AddedFromRight") {
return position + span.text.length;
}
position += span.text.length;
if (position === cursor && span.history === "RemovedFromRight") {
return position - span.text.length;
}
}
private static findWhereToMoveCursor(
cursor: number,
spans: SpanWithHistory[]
): number | null {
let position = 0;
for (const span of spans) {
// left and origin are the same
if (position === cursor && span.history === "AddedFromRight") {
return position + span.text.length;
}
position += span.text.length;
if (position === cursor && span.history === "RemovedFromRight") {
return position - span.text.length;
}
}
return null;
}
return null;
}
public update(update: ViewUpdate): void {
const original = update.startState.doc.toString();
const edited = update.state.doc.toString();
public update(update: ViewUpdate): void {
const original = update.startState.doc.toString();
const edited = update.state.doc.toString();
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
original,
edited
);
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
original,
edited
);
const decorations: Range<Decoration>[] = [];
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
update.view
);
RemoteCursorsPluginValue.cursors
.filter(({ path }) => path == relative_path)
.forEach(({ name, span: { start, end } }) => {
const color = utils.getRandomColor(name);
const startLine = update.view.state.doc.lineAt(start);
const endLine = update.view.state.doc.lineAt(end);
const decorations: Range<Decoration>[] = [];
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
update.view
);
RemoteCursorsPluginValue.cursors
.filter(({ path }) => path == relative_path)
.forEach(({ name, span: { start, end } }) => {
const color = utils.getRandomColor(name);
const startLine = update.view.state.doc.lineAt(start);
const endLine = update.view.state.doc.lineAt(end);
const attributes = {
style: `background-color: ${color};`
};
const attributes = {
style: `background-color: ${color};`
};
if (startLine.number === endLine.number) {
// selected content in a single line.
decorations.push({
from: start,
to: end,
value: Decoration.mark({
attributes
})
});
} else {
// selected content in multiple lines
// first, render text-selection in the first line
decorations.push({
from: start,
to: startLine.from + startLine.length,
value: Decoration.mark({
attributes
})
});
if (startLine.number === endLine.number) {
// selected content in a single line.
decorations.push({
from: start,
to: end,
value: Decoration.mark({
attributes
})
});
} else {
// selected content in multiple lines
// first, render text-selection in the first line
decorations.push({
from: start,
to: startLine.from + startLine.length,
value: Decoration.mark({
attributes
})
});
// render text-selection in the lines between the first and last line
for (
let i = startLine.number + 1;
i < endLine.number;
i++
) {
const currentLine = update.view.state.doc.line(i);
decorations.push({
from: currentLine.from,
to: currentLine.to,
value: Decoration.mark({
attributes
})
});
}
// render text-selection in the lines between the first and last line
for (
let i = startLine.number + 1;
i < endLine.number;
i++
) {
const currentLine = update.view.state.doc.line(i);
decorations.push({
from: currentLine.from,
to: currentLine.to,
value: Decoration.mark({
attributes
})
});
}
// render text-selection in the last line
decorations.push({
from: endLine.from,
to: end,
value: Decoration.mark({
attributes
})
});
}
// render text-selection in the last line
decorations.push({
from: endLine.from,
to: end,
value: Decoration.mark({
attributes
})
});
}
decorations.push({
from: end,
to: end,
value: Decoration.widget({
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
block: false,
widget: new RemoteCursorWidget(color, name)
})
});
});
decorations.push({
from: end,
to: end,
value: Decoration.widget({
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
block: false,
widget: new RemoteCursorWidget(color, name)
})
});
});
this.decorations = Decoration.set(decorations, true);
}
this.decorations = Decoration.set(decorations, true);
}
}
export const remoteCursorsPlugin = ViewPlugin.fromClass(
RemoteCursorsPluginValue,
{
decorations: (v) => v.decorations
}
RemoteCursorsPluginValue,
{
decorations: (v) => v.decorations
}
);

View file

@ -1,43 +1,43 @@
.vault-link-sync-status {
position: absolute;
right: var(--size-4-4);
top: var(--size-4-2);
opacity: 0.7;
cursor: pointer;
position: absolute;
right: var(--size-4-4);
top: var(--size-4-2);
opacity: 0.7;
cursor: pointer;
> span {
opacity: 0;
position: absolute;
min-width: 200px;
text-align: right;
padding-right: var(--size-2-2);
> span {
opacity: 0;
position: absolute;
min-width: 200px;
text-align: right;
padding-right: var(--size-2-2);
top: 50%;
left: 0;
transform: translateY(-50%) translateX(-100%) translateY(-2px);
transition: opacity 200ms;
}
top: 50%;
left: 0;
transform: translateY(-50%) translateX(-100%) translateY(-2px);
transition: opacity 200ms;
}
&:hover {
> span {
opacity: 1;
}
}
&:hover {
> span {
opacity: 1;
}
}
> .icon {
line-height: 0;
}
> .icon {
line-height: 0;
}
&.loading > .icon {
animation: spin 2s linear infinite;
&.loading > .icon {
animation: spin 2s linear infinite;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
}

View file

@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin";
import { HistoryView } from "../history/history-view";
export class EditorStatusDisplayManager {
private static readonly UPDATE_INTERVAL_IN_MS = 100;
private static readonly UPDATE_INTERVAL_IN_MS = 100;
private readonly intervalId: NodeJS.Timeout;
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
private readonly intervalId: NodeJS.Timeout;
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
public constructor(
private readonly plugin: VaultLinkPlugin,
private readonly workspace: Workspace,
private readonly client: SyncClient
) {
this.intervalId = setInterval(() => {
this.updateEditorStatusDisplay();
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
}
public constructor(
private readonly plugin: VaultLinkPlugin,
private readonly workspace: Workspace,
private readonly client: SyncClient
) {
this.intervalId = setInterval(() => {
this.updateEditorStatusDisplay();
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
}
public dispose(): void {
clearInterval(this.intervalId);
}
public dispose(): void {
clearInterval(this.intervalId);
}
private updateEditorStatusDisplay(): void {
this.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof FileView) {
const filePath = leaf.view.file?.path;
if (filePath == null) {
return;
}
private updateEditorStatusDisplay(): void {
this.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof FileView) {
const filePath = leaf.view.file?.path;
if (filePath == null) {
return;
}
const element = this.getElementFromLeaf(leaf.view);
if (element == null) {
return;
}
const element = this.getElementFromLeaf(leaf.view);
if (element == null) {
return;
}
const previousStatus = this.lastStatuses.get(filePath);
const currentStatus =
this.client.getDocumentSyncingStatus(filePath);
if (previousStatus === currentStatus) {
return;
}
this.lastStatuses.set(filePath, currentStatus);
const previousStatus = this.lastStatuses.get(filePath);
const currentStatus =
this.client.getDocumentSyncingStatus(filePath);
if (previousStatus === currentStatus) {
return;
}
this.lastStatuses.set(filePath, currentStatus);
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
element.remove();
return;
}
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
element.remove();
return;
}
if (currentStatus == DocumentSyncStatus.SYNCING) {
element.classList.add("loading");
} else {
element.classList.remove("loading");
}
if (currentStatus == DocumentSyncStatus.SYNCING) {
element.classList.add("loading");
} else {
element.classList.remove("loading");
}
const iconContainer = element.querySelector(".icon");
if (iconContainer != null) {
setIcon(
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
currentStatus == DocumentSyncStatus.SYNCING
? "loader"
: "circle-check"
);
}
}
});
}
const iconContainer = element.querySelector(".icon");
if (iconContainer != null) {
setIcon(
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
currentStatus == DocumentSyncStatus.SYNCING
? "loader"
: "circle-check"
);
}
}
});
}
private getElementFromLeaf(fileView: FileView): Element | undefined {
const parent = fileView.contentEl.querySelector(".cm-editor");
if (parent == null) {
return;
}
private getElementFromLeaf(fileView: FileView): Element | undefined {
const parent = fileView.contentEl.querySelector(".cm-editor");
if (parent == null) {
return;
}
return (
parent.querySelector(".vault-link-sync-status") ??
parent.createDiv(
{
cls: "vault-link-sync-status"
},
(el) => {
el.createSpan({ text: "VaultLink sync state" });
el.createDiv({
cls: "icon"
});
el.onclick = async (): Promise<void> =>
this.plugin.activateView(HistoryView.TYPE);
}
)
);
}
return (
parent.querySelector(".vault-link-sync-status") ??
parent.createDiv(
{
cls: "vault-link-sync-status"
},
(el) => {
el.createSpan({ text: "VaultLink sync state" });
el.createDiv({
cls: "icon"
});
el.onclick = async (): Promise<void> =>
this.plugin.activateView(HistoryView.TYPE);
}
)
);
}
}

View file

@ -1,61 +1,61 @@
.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;
word-break: break-word;
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;
word-break: break-word;
&.clickable {
cursor: pointer;
}
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
&.skipped {
background-color: rgba(var(--color-green-rgb), 0.08);
}
&.skipped {
background-color: rgba(var(--color-green-rgb), 0.08);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-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;
}
@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);
margin: 0;
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
margin: 0;
> span {
margin-bottom: var(--size-4-1);
}
}
> span {
margin-bottom: var(--size-4-1);
}
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.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;
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -7,234 +7,234 @@ 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;
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
>();
private historyContainer: HTMLElement | undefined;
private readonly historyEntryToElement = new Map<
HistoryEntry,
HTMLElement
>();
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = HistoryView.ICON;
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = HistoryView.ICON;
this.client.addSyncHistoryUpdateListener(async () =>
this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
})
);
}
this.client.addSyncHistoryUpdateListener(async () =>
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 SyncType.MOVE:
return "move-right";
case SyncType.SKIPPED:
return "circle-slash";
case undefined:
default:
return "";
}
}
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 SyncType.MOVE:
return "move-right";
case SyncType.SKIPPED:
return "circle-slash";
case undefined:
default:
return "";
}
}
private static renderSyncItemTitle(
element: HTMLElement,
entry: HistoryEntry
): void {
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
if (syncTypeIcon) {
setIcon(element.createDiv(), syncTypeIcon);
}
private static renderSyncItemTitle(
element: HTMLElement,
entry: HistoryEntry
): void {
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
if (syncTypeIcon) {
setIcon(element.createDiv(), syncTypeIcon);
}
let fileName = entry.details.relativePath.split("/").pop() ?? "";
fileName = fileName.replace(/\.md$/, "");
let fileName = entry.details.relativePath.split("/").pop() ?? "";
fileName = fileName.replace(/\.md$/, "");
element.createEl("span", {
text:
entry.details.type === SyncType.SKIPPED
? `Skipped: ${fileName}`
: fileName
});
}
element.createEl("span", {
text:
entry.details.type === SyncType.SKIPPED
? `Skipped: ${fileName}`
: fileName
});
}
private static updateTimeSince(
element: HTMLElement,
entry: HistoryEntry
): void {
const timestampElement = element.querySelector(
".history-card-timestamp"
);
private static updateTimeSince(
element: HTMLElement,
entry: HistoryEntry
): void {
const timestampElement = element.querySelector(
".history-card-timestamp"
);
if (timestampElement != null) {
timestampElement.textContent =
HistoryView.getTimestampAndAuthor(entry);
}
}
if (timestampElement != null) {
timestampElement.textContent =
HistoryView.getTimestampAndAuthor(entry);
}
}
private static getTimestampAndAuthor(entry: HistoryEntry): string {
let content = intlFormatDistance(entry.timestamp, new Date());
if ("author" in entry && entry.author !== undefined) {
content += ` by ${entry.author}`;
}
return content;
}
private static getTimestampAndAuthor(entry: HistoryEntry): string {
let content = intlFormatDistance(entry.timestamp, new Date());
if ("author" in entry && entry.author !== undefined) {
content += ` by ${entry.author}`;
}
return content;
}
public getViewType(): string {
return HistoryView.TYPE;
}
public getViewType(): string {
return HistoryView.TYPE;
}
public getDisplayText(): string {
return "VaultLink history";
}
public getDisplayText(): string {
return "VaultLink history";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.createEl("h4", { text: "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" });
this.historyContainer = container.createDiv({ cls: "logs-container" });
await this.updateView();
this.clearTimer();
this.timer = setInterval(
() =>
void this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
}),
1000
);
}
await this.updateView();
this.clearTimer();
this.timer = setInterval(
() =>
void this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
}),
1000
);
}
public async onClose(): Promise<void> {
this.clearTimer();
}
public async onClose(): Promise<void> {
this.clearTimer();
}
private clearTimer(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private clearTimer(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private async updateView(): Promise<void> {
const container = this.historyContainer;
if (container === undefined) {
return;
}
private async updateView(): Promise<void> {
const container = this.historyContainer;
if (container === undefined) {
return;
}
// entries are newest first, but we prepend new ones
const entries = this.client.getHistoryEntries().toReversed();
// entries are newest first, but we prepend new ones
const entries = this.client.getHistoryEntries().toReversed();
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
// Clear the "No update has happened yet" message
container.empty();
}
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;
}
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 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);
}
}
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."
});
}
}
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.details.relativePath) !=
null
) {
card.addEventListener("click", () => {
this.app.workspace
.openLinkText(
entry.details.relativePath,
entry.details.relativePath,
false
)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to open link for ${entry.details.relativePath}: ${error}`
);
});
});
private createHistoryCard(
container: HTMLElement,
entry: HistoryEntry
): HTMLElement {
return container.createDiv(
{
cls: ["history-card", entry.status.toLocaleLowerCase()]
},
(card) => {
if (
this.app.vault.getFileByPath(entry.details.relativePath) !=
null
) {
card.addEventListener("click", () => {
this.app.workspace
.openLinkText(
entry.details.relativePath,
entry.details.relativePath,
false
)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to open link for ${entry.details.relativePath}: ${error}`
);
});
});
card.addClass("clickable");
}
card.addClass("clickable");
}
card.createDiv(
{
cls: "history-card-header"
},
(header) => {
header.createEl(
"h5",
{
cls: "history-card-title"
},
(title) => {
HistoryView.renderSyncItemTitle(title, entry);
}
);
card.createDiv(
{
cls: "history-card-header"
},
(header) => {
header.createEl(
"h5",
{
cls: "history-card-title"
},
(title) => {
HistoryView.renderSyncItemTitle(title, entry);
}
);
header.createSpan({
text: HistoryView.getTimestampAndAuthor(entry),
cls: "history-card-timestamp"
});
}
);
header.createSpan({
text: HistoryView.getTimestampAndAuthor(entry),
cls: "history-card-timestamp"
});
}
);
const body =
entry.details.type === SyncType.MOVE
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
: `${entry.message}.`;
const body =
entry.details.type === SyncType.MOVE
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
: `${entry.message}.`;
card.createEl("p", {
text: body,
cls: "history-card-message"
});
}
);
}
card.createEl("p", {
text: body,
cls: "history-card-message"
});
}
);
}
}

View file

@ -1,74 +1,74 @@
.logs-view {
display: flex;
flex-direction: column;
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);
.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;
}
h4 {
margin: 0;
}
.logs-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
.logs-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
button {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
button {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
select {
cursor: pointer;
}
}
}
select {
cursor: pointer;
}
}
}
.logs-container {
max-width: 100%;
overflow-y: auto;
.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;
.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);
}
.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);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-base-100);
}
&.INFO {
color: var(--color-base-100);
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
}

View file

@ -6,189 +6,189 @@ 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";
public static readonly TYPE = "logs-view";
public static readonly ICON = "logs";
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
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;
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();
});
}
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 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];
}
private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0];
}
public getViewType(): string {
return LogsView.TYPE;
}
public getViewType(): string {
return LogsView.TYPE;
}
public getDisplayText(): string {
return "VaultLink logs";
}
public getDisplayText(): string {
return "VaultLink logs";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.addClass("logs-view");
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 }
];
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"
});
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
const controls = verbositySection.createDiv({
cls: "logs-controls"
});
const controls = verbositySection.createDiv({
cls: "logs-controls"
});
const copyButton = controls.createEl("button", {
text: "Copy logs",
cls: "clickable-icon"
});
setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => {
this.copyLogsToClipboard();
});
const copyButton = controls.createEl("button", {
text: "Copy logs",
cls: "clickable-icon"
});
setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => {
this.copyLogsToClipboard();
});
controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
dropdown.value = this.minLogLevel;
dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
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?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView();
}
this.updateView();
}
private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel);
private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel);
if (logs.length === 0) {
new Notice("No logs to copy");
return;
}
if (logs.length === 0) {
new Notice("No logs to copy");
return;
}
const formattedLogs = logs
.map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`;
})
.join("\n");
const formattedLogs = logs
.map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`;
})
.join("\n");
navigator.clipboard
.writeText(formattedLogs)
.then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`);
})
.catch((error: unknown) => {
this.client.logger.error(
`Failed to copy logs to clipboard: ${error}`
);
new Notice("Failed to copy logs to clipboard");
});
}
navigator.clipboard
.writeText(formattedLogs)
.then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`);
})
.catch((error: unknown) => {
this.client.logger.error(
`Failed to copy logs to clipboard: ${error}`
);
new Notice("Failed to copy logs to clipboard");
});
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
return;
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
return;
}
const logs = this.client.logger.getMessages(this.minLogLevel);
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();
}
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;
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;
}
logs.forEach((message) => {
if (this.logLineToElement.has(message)) {
return;
}
const element = LogsView.createLogLineElement(container, message);
const element = LogsView.createLogLineElement(container, message);
this.logLineToElement.set(message, element);
});
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);
}
}
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;
}
}
if (logs.length === 0) {
container.empty();
container.createEl("p", {
text: "No logs available yet."
});
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
}

View file

@ -1,134 +1,134 @@
@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);
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);
}
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.vault-link-settings-container {
position: relative;
position: relative;
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.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);
}
}
.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);
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
textarea {
resize: none;
height: 75px;
}
.applying-changes-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%);
z-index: 10;
backdrop-filter: blur(10px);
.applying-changes-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%);
z-index: 10;
backdrop-filter: blur(10px);
.spinner-container {
background-color: rgba(var(--background-primary), 0.5);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.spinner-container {
background-color: rgba(var(--background-primary), 0.5);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--background-modifier-border);
border-top-color: var(--interactive-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--background-modifier-border);
border-top-color: var(--interactive-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-text {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: 500;
}
.spinner-text {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: 500;
}
.spinner-warning {
color: var(--text-muted);
font-size: var(--font-ui-small);
text-align: center;
margin-top: var(--size-2-2);
}
}
.spinner-warning {
color: var(--text-muted);
font-size: var(--font-ui-small);
text-align: center;
margin-top: var(--size-2-2);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
to {
transform: rotate(360deg);
}
}
&.applying-changes {
.setting-item-control {
pointer-events: none;
opacity: 0.5;
}
&.applying-changes {
.setting-item-control {
pointer-events: none;
opacity: 0.5;
}
button:not(.applying-changes-overlay button) {
pointer-events: none;
opacity: 0.5;
}
button:not(.applying-changes-overlay button) {
pointer-events: none;
opacity: 0.5;
}
input,
textarea,
select {
pointer-events: none;
opacity: 0.5;
}
}
}
}
input,
textarea,
select {
pointer-events: none;
opacity: 0.5;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,32 +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);
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);
}
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}

View file

@ -1,147 +1,147 @@
import "./status-description.scss";
import type {
HistoryStats,
NetworkConnectionStatus,
SyncClient
HistoryStats,
NetworkConnectionStatus,
SyncClient
} from "sync-client";
import { utils } from "sync-client";
export class StatusDescription {
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private readonly statusChangeListeners: (() => unknown)[] = [];
private readonly statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();
syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateDescription();
});
syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateDescription();
});
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateDescription();
}
);
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateDescription();
}
);
this.syncClient.addWebSocketStatusChangeListener(async () =>
this.updateConnectionState()
);
this.syncClient.addWebSocketStatusChangeListener(async () =>
this.updateConnectionState()
);
this.syncClient.addOnSettingsChangeListener(async () =>
this.updateConnectionState()
);
}
this.syncClient.addOnSettingsChangeListener(async () =>
this.updateConnectionState()
);
}
public async updateConnectionState(): Promise<void> {
this.lastConnectionState = await this.syncClient.checkConnection();
this.updateDescription();
}
public async updateConnectionState(): Promise<void> {
this.lastConnectionState = await this.syncClient.checkConnection();
this.updateDescription();
}
public addStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners.push(listener);
}
public removeStatusChangeListener(listener: () => unknown): void {
utils.removeFromArray(this.statusChangeListeners, listener);
}
public addStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners.push(listener);
}
public removeStatusChangeListener(listener: () => unknown): void {
utils.removeFromArray(this.statusChangeListeners, listener);
}
public renderStatusDescription(container: HTMLElement): void {
container.empty();
container.addClass("status-description");
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 == 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.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;
}
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: "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. `
});
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;
}
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."
});
}
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();
});
}
private updateDescription(): void {
this.statusChangeListeners.forEach((listener) => {
listener();
});
}
}