mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 19:43:20 +00:00
Refine status item placement and inactive styling
This commit is contained in:
1 parent
68dc40ff05
commit
e7f7e62cfa
9 files changed
+321
-44
No files matched your search
@@ -105,7 +105,7 @@ Synchronisation status is shown in the status bar with the following icons.
|
|||||||
|
|
||||||
To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files.
|
To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files.
|
||||||
|
|
||||||
When CouchDB change history is being compared, `↓ 0 (⏩ 1200)` can appear before any new documents arrive. The count is the number of candidate changes-feed entries compared during the current replication attempt, including repeated entries on retries. It is separate from downloaded Chunks and Metadata, and is neither a Vault file count nor a count of entries already present locally. It disappears after three seconds without a change and reappears when the count changes. The ⏩ status icon follows comparison activity independently of that timer.
|
When CouchDB change history is being compared, `(⏩ 1200) ↑ 0 ↓ 0` can appear before any new documents arrive. The count is the number of candidate changes-feed entries compared during the current replication attempt, including repeated entries on retries. It is separate from downloaded Chunks and Metadata, and is neither a Vault file count nor a count of entries already present locally. It appears before the upload count, disappears after three seconds without a change, and reappears when the count changes. The ⏩ status icon follows comparison activity independently of that timer.
|
||||||
|
|
||||||
### Customising status items with CSS snippets
|
### Customising status items with CSS snippets
|
||||||
|
|
||||||
@@ -120,7 +120,9 @@ The status bar and editor overlay use individual spans with the common `livesync
|
|||||||
|
|
||||||
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
|
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
|
||||||
|
|
||||||
All items also have `livesync-stat-item`. Numeric children use `livesync-stat-value`, tabular digits, and right alignment. Each numeric item retains its widest displayed digit count until it is hidden, reducing movement as counts decrease.
|
All items also have `livesync-stat-item`. Numeric children use `livesync-stat-value`, tabular digits, and right alignment. Each numeric item retains its widest displayed digit count during an active period, reducing movement as counts decrease. The editor overlay uses wider gaps between items, while remaining counts stay beside their transfer totals.
|
||||||
|
|
||||||
|
Inactive items receive `livesync-stat-inactive` instead of the HTML `hidden` attribute. The default CSS hides them immediately. They retain their last text, numeric width, and position amongst the other items so that CSS snippets can customise how they disappear. The numeric width starts afresh when an item becomes active again.
|
||||||
|
|
||||||
## Tips and Troubleshooting
|
## Tips and Troubleshooting
|
||||||
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
|
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function projectReplicationStatusLabel(
|
|||||||
fastForwardCount > 0
|
fastForwardCount > 0
|
||||||
? fastForwardCount
|
? fastForwardCount
|
||||||
: undefined;
|
: undefined;
|
||||||
const fastForwardSuffix = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
||||||
|
|
||||||
const parts: StatusBarPart[] = [];
|
const parts: StatusBarPart[] = [];
|
||||||
if (networkStatus.trackedRequestCount > 0) {
|
if (networkStatus.trackedRequestCount > 0) {
|
||||||
@@ -177,10 +177,6 @@ export function projectReplicationStatusLabel(
|
|||||||
parts.push({ key: "network", text: networkActivity });
|
parts.push({ key: "network", text: networkActivity });
|
||||||
}
|
}
|
||||||
parts.push({ key: "sync", text: "Sync: " + icon });
|
parts.push({ key: "sync", text: "Sync: " + icon });
|
||||||
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
|
||||||
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
|
|
||||||
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
|
|
||||||
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
|
|
||||||
if (visibleFastForwardCount !== undefined) {
|
if (visibleFastForwardCount !== undefined) {
|
||||||
parts.push({
|
parts.push({
|
||||||
key: "ff",
|
key: "ff",
|
||||||
@@ -190,6 +186,10 @@ export function projectReplicationStatusLabel(
|
|||||||
title: "Compared CouchDB feed entries",
|
title: "Compared CouchDB feed entries",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
||||||
|
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
|
||||||
|
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
|
||||||
|
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon,
|
icon,
|
||||||
@@ -197,13 +197,13 @@ export function projectReplicationStatusLabel(
|
|||||||
networkActivity +
|
networkActivity +
|
||||||
"Sync: " +
|
"Sync: " +
|
||||||
icon +
|
icon +
|
||||||
|
fastForwardLabel +
|
||||||
" ↑ " +
|
" ↑ " +
|
||||||
sent +
|
sent +
|
||||||
pushRemaining.text +
|
pushRemaining.text +
|
||||||
" ↓ " +
|
" ↓ " +
|
||||||
arrived +
|
arrived +
|
||||||
pullRemaining.text +
|
pullRemaining.text,
|
||||||
fastForwardSuffix,
|
|
||||||
parts,
|
parts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -126,8 +126,9 @@ describe("projectReplicationStatusLabel", () => {
|
|||||||
1200
|
1200
|
||||||
);
|
);
|
||||||
expect(transferred.icon).toBe("⚡");
|
expect(transferred.icon).toBe("⚡");
|
||||||
expect(transferred.message).toBe("Sync: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
|
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||||
expect(transferred.parts[transferred.parts.length - 1]).toMatchObject({
|
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
|
||||||
|
expect(transferred.parts[1]).toMatchObject({
|
||||||
key: "ff",
|
key: "ff",
|
||||||
text: " (⏩ ",
|
text: " (⏩ ",
|
||||||
value: 1200,
|
value: 1200,
|
||||||
@@ -144,7 +145,7 @@ describe("projectReplicationStatusLabel", () => {
|
|||||||
1200
|
1200
|
||||||
);
|
);
|
||||||
expect(idle.icon).toBe("⚡");
|
expect(idle.icon).toBe("⚡");
|
||||||
expect(idle.message).toBe("Sync: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
|
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const INACTIVE_CLASS = "livesync-stat-inactive";
|
||||||
|
|
||||||
export type StatusBarPart = {
|
export type StatusBarPart = {
|
||||||
key: string;
|
key: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -22,7 +24,6 @@ type RenderedItem = {
|
|||||||
|
|
||||||
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
|
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
|
||||||
const items = new Map<string, RenderedItem>();
|
const items = new Map<string, RenderedItem>();
|
||||||
const managedElements = new Set<HTMLSpanElement>();
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const setText = (node: Text, text: string) => {
|
const setText = (node: Text, text: string) => {
|
||||||
@@ -33,6 +34,13 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
if (element.hidden !== hidden) element.hidden = hidden;
|
if (element.hidden !== hidden) element.hidden = hidden;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setInactive = (element: HTMLElement, inactive: boolean) => {
|
||||||
|
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
|
||||||
|
element.classList.toggle(INACTIVE_CLASS, inactive);
|
||||||
|
if (inactive) element.setAttribute("aria-hidden", "true");
|
||||||
|
else element.removeAttribute("aria-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
const setWidth = (element: HTMLElement, width: string) => {
|
const setWidth = (element: HTMLElement, width: string) => {
|
||||||
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
|
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
|
||||||
element.setCssProps({ "--livesync-stat-width": width });
|
element.setCssProps({ "--livesync-stat-width": width });
|
||||||
@@ -58,7 +66,6 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
|
|
||||||
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
|
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
|
||||||
items.set(key, item);
|
items.set(key, item);
|
||||||
managedElements.add(element);
|
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,20 +96,16 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
|
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
|
||||||
|
const desiredOrder = visibleItems.map((item) => item.element);
|
||||||
|
const visibleElements = new Set(desiredOrder);
|
||||||
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
||||||
managedElements.has(node as HTMLSpanElement)
|
visibleElements.has(node as HTMLSpanElement)
|
||||||
);
|
);
|
||||||
const visibleElements = new Set(visibleItems.map((item) => item.element));
|
|
||||||
const desiredOrder = [
|
|
||||||
...visibleItems.map((item) => item.element),
|
|
||||||
...currentOrder.filter((element) => !visibleElements.has(element)),
|
|
||||||
];
|
|
||||||
if (
|
if (
|
||||||
currentOrder.length === desiredOrder.length &&
|
currentOrder.length === desiredOrder.length &&
|
||||||
currentOrder.every((node, index) => node === desiredOrder[index])
|
currentOrder.every((node, index) => node === desiredOrder[index])
|
||||||
) {
|
)
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
let reference: ChildNode | null = currentOrder[0] ?? null;
|
let reference: ChildNode | null = currentOrder[0] ?? null;
|
||||||
for (const element of desiredOrder) {
|
for (const element of desiredOrder) {
|
||||||
@@ -110,7 +113,7 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
const childNodes = Array.from(parent.childNodes);
|
const childNodes = Array.from(parent.childNodes);
|
||||||
const elementIndex = childNodes.indexOf(element);
|
const elementIndex = childNodes.indexOf(element);
|
||||||
reference =
|
reference =
|
||||||
childNodes.slice(elementIndex + 1).find((node) => managedElements.has(node as HTMLSpanElement)) ?? null;
|
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,17 +126,16 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
let item = items.get(part.key);
|
let item = items.get(part.key);
|
||||||
if (!item) item = createItem(part.key);
|
if (!item) item = createItem(part.key);
|
||||||
|
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
|
||||||
updateItem(item, part);
|
updateItem(item, part);
|
||||||
setHidden(item.element, false);
|
setInactive(item.element, false);
|
||||||
visibleItems.push(item);
|
visibleItems.push(item);
|
||||||
visibleKeys.add(part.key);
|
visibleKeys.add(part.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, item] of items) {
|
for (const [key, item] of items) {
|
||||||
if (visibleKeys.has(key)) continue;
|
if (visibleKeys.has(key)) continue;
|
||||||
setHidden(item.element, true);
|
setInactive(item.element, true);
|
||||||
setWidth(item.value, "");
|
|
||||||
item.widestValueLength = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderIfNeeded(visibleItems);
|
reorderIfNeeded(visibleItems);
|
||||||
@@ -145,7 +147,6 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
|
|||||||
if (item.element.parentNode === parent) parent.removeChild(item.element);
|
if (item.element.parentNode === parent) parent.removeChild(item.element);
|
||||||
}
|
}
|
||||||
items.clear();
|
items.clear();
|
||||||
managedElements.clear();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,33 @@ class TestElement {
|
|||||||
private currentClassName = "";
|
private currentClassName = "";
|
||||||
private currentHidden = false;
|
private currentHidden = false;
|
||||||
private currentTitle = "";
|
private currentTitle = "";
|
||||||
|
private attributes = new Map<string, string>();
|
||||||
|
readonly classList = {
|
||||||
|
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
|
||||||
|
toggle: (token: string, force: boolean): boolean => {
|
||||||
|
const tokens = this.currentClassName.split(" ").filter(Boolean);
|
||||||
|
const present = tokens.includes(token);
|
||||||
|
if (present !== force) {
|
||||||
|
this.className = force
|
||||||
|
? [...tokens, token].join(" ")
|
||||||
|
: tokens.filter((item) => item !== token).join(" ");
|
||||||
|
}
|
||||||
|
return force;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, value: string) {
|
||||||
|
this.document.mutations++;
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttribute(name: string) {
|
||||||
|
if (this.attributes.delete(name)) this.document.mutations++;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly document: TestDocument,
|
private readonly document: TestDocument,
|
||||||
@@ -173,7 +200,9 @@ function findValue(item: TestElement): TestElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleItems(root: TestElement): TestElement[] {
|
function visibleItems(root: TestElement): TestElement[] {
|
||||||
return root.children.filter((child) => !child.hidden && child.className.split(" ").includes("livesync-stat-item"));
|
return root.children.filter(
|
||||||
|
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createStatusBarRenderer", () => {
|
describe("createStatusBarRenderer", () => {
|
||||||
@@ -195,8 +224,10 @@ describe("createStatusBarRenderer", () => {
|
|||||||
expect(findValue(queue).className).toBe("livesync-stat-value");
|
expect(findValue(queue).className).toBe("livesync-stat-value");
|
||||||
|
|
||||||
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
|
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
|
||||||
expect(sync.hidden).toBe(true);
|
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||||
expect(queue.hidden).toBe(false);
|
expect(sync.hidden).toBe(false);
|
||||||
|
expect(sync.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||||
expect(queue.textContent).toBe(" (queued: 3)");
|
expect(queue.textContent).toBe(" (queued: 3)");
|
||||||
renderer.render([{ key: "queue", text: "Queue idle" }]);
|
renderer.render([{ key: "queue", text: "Queue idle" }]);
|
||||||
expect(queue.textContent).toBe("Queue idle");
|
expect(queue.textContent).toBe("Queue idle");
|
||||||
@@ -239,7 +270,7 @@ describe("createStatusBarRenderer", () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets numeric width while omitted and reuses the same item when it returns", () => {
|
it("retains omitted content and width, then resets width when the same item returns", () => {
|
||||||
const { root, parent } = createFixture();
|
const { root, parent } = createFixture();
|
||||||
const renderer = createStatusBarRenderer(parent);
|
const renderer = createStatusBarRenderer(parent);
|
||||||
|
|
||||||
@@ -249,16 +280,49 @@ describe("createStatusBarRenderer", () => {
|
|||||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||||
|
|
||||||
renderer.render([]);
|
renderer.render([]);
|
||||||
expect(item.hidden).toBe(true);
|
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("");
|
expect(item.hidden).toBe(false);
|
||||||
|
expect(item.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(item.textContent).toBe(" 1234");
|
||||||
|
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||||
|
|
||||||
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
||||||
expect(findItem(root, "count")).toBe(item);
|
expect(findItem(root, "count")).toBe(item);
|
||||||
expect(item.hidden).toBe(false);
|
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||||
|
expect(item.getAttribute("aria-hidden")).toBeNull();
|
||||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
|
||||||
|
const { document, root, parent } = createFixture();
|
||||||
|
const renderer = createStatusBarRenderer(parent);
|
||||||
|
const first = { key: "first", text: "first " };
|
||||||
|
const count = { key: "count", text: "count ", value: 1234 };
|
||||||
|
const last = { key: "last", text: "last" };
|
||||||
|
renderer.render([first, count, last]);
|
||||||
|
const initialOrder = [...root.children];
|
||||||
|
const omitted = findItem(root, "count");
|
||||||
|
|
||||||
|
renderer.render([first, last]);
|
||||||
|
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
|
||||||
|
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
|
||||||
|
const mutations = document.mutations;
|
||||||
|
renderer.render([first, last]);
|
||||||
|
expect(document.mutations).toBe(mutations);
|
||||||
|
|
||||||
|
const added = { key: "added", text: "added " };
|
||||||
|
renderer.render([first, added, last]);
|
||||||
|
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
|
||||||
|
expect(omitted.textContent).toBe("count 1234");
|
||||||
|
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||||
|
|
||||||
|
renderer.render([first, count, added, last]);
|
||||||
|
expect(findItem(root, "count")).toBe(omitted);
|
||||||
|
expect(visibleItems(root)).toEqual(root.children);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("reorders existing nodes without replacing them", () => {
|
it("reorders existing nodes without replacing them", () => {
|
||||||
const { root, parent } = createFixture();
|
const { root, parent } = createFixture();
|
||||||
const renderer = createStatusBarRenderer(parent);
|
const renderer = createStatusBarRenderer(parent);
|
||||||
|
|||||||
+19
@@ -844,6 +844,25 @@ body.is-mobile .livesync-compatibility-review-notice {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.livesync-stat-item.livesync-stat-inactive {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.syncstatusbar .livesync-stat-item {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.livesync-status-statusline
|
||||||
|
> .livesync-stat-item:not(.livesync-stat-inactive)
|
||||||
|
~ .livesync-stat-item:not(
|
||||||
|
.livesync-stat-push-remaining,
|
||||||
|
.livesync-stat-pull-remaining,
|
||||||
|
.livesync-stat-ff,
|
||||||
|
.livesync-stat-p2p
|
||||||
|
) {
|
||||||
|
margin-inline-start: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.livesync-stat-value {
|
.livesync-stat-value {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: var(--livesync-stat-width, auto);
|
width: var(--livesync-stat-width, auto);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ npm run test:e2e:obsidian:focused -- replication-status
|
|||||||
|
|
||||||
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
||||||
|
|
||||||
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, and unchanged DOM nodes for repeated values. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
|
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, and unchanged DOM nodes for repeated values. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. A separate session enters mobile emulation before loading the plug-in and measures the editor item gaps at a 390px viewport width. The desktop checks also ensure that the status bar keeps its compact spacing. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
|
||||||
|
|
||||||
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including visible radio choices for original names and pseudonyms, their default and keyboard navigation, the default-on integrity-only remote option, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout at 800×600 and 375×667, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
|
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including visible radio choices for original names and pseudonyms, their default and keyboard navigation, the default-on integrity-only remote option, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout at 800×600 and 375×667, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../r
|
|||||||
import { withObsidianPage } from "../runner/ui.ts";
|
import { withObsidianPage } from "../runner/ui.ts";
|
||||||
import { createTemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault } from "../runner/vault.ts";
|
||||||
import type { Page } from "playwright";
|
import type { Page } from "playwright";
|
||||||
|
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
|
||||||
|
|
||||||
const fixturePath = "replication-status-e2e.md";
|
const fixturePath = "replication-status-e2e.md";
|
||||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
|
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
|
||||||
@@ -40,10 +41,12 @@ type TestWorkspace = {
|
|||||||
getActiveFile?: () => { path?: string } | null;
|
getActiveFile?: () => { path?: string } | null;
|
||||||
};
|
};
|
||||||
type TestLogModule = {
|
type TestLogModule = {
|
||||||
|
adjustStatusDivPosition?: () => void;
|
||||||
statusLog?: WritableSource<string>;
|
statusLog?: WritableSource<string>;
|
||||||
statusDiv?: HTMLElement;
|
statusDiv?: HTMLElement;
|
||||||
statusLine?: HTMLElement;
|
statusLine?: HTMLElement;
|
||||||
statusLineRenderer?: { render?: (parts: readonly unknown[]) => void };
|
statusLineRenderer?: { render?: (parts: readonly unknown[]) => void };
|
||||||
|
statusBarRenderer?: { render?: (parts: readonly unknown[]) => void };
|
||||||
statusBarLabels?: WritableSource<{ parts: readonly unknown[] }>;
|
statusBarLabels?: WritableSource<{ parts: readonly unknown[] }>;
|
||||||
__addLog?: (message: unknown, level?: number, key?: string) => void;
|
__addLog?: (message: unknown, level?: number, key?: string) => void;
|
||||||
};
|
};
|
||||||
@@ -171,7 +174,7 @@ async function appendLiveSyncLog(page: Page, message: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForPartText(page: Page, key: string, expected: string): Promise<void> {
|
async function waitForPartText(page: Page, key: string, expected: string): Promise<void> {
|
||||||
const selector = `.livesync-status-statusline .livesync-stat-${key} .livesync-stat-value`;
|
const selector = `.livesync-status-statusline .livesync-stat-${key}:not(.livesync-stat-inactive) .livesync-stat-value`;
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
({ selector, expected }) => document.querySelector(selector)?.textContent?.trim() === expected,
|
({ selector, expected }) => document.querySelector(selector)?.textContent?.trim() === expected,
|
||||||
{ selector, expected },
|
{ selector, expected },
|
||||||
@@ -442,11 +445,152 @@ async function assertNumericWidths(
|
|||||||
return await measureNumericWidths(page);
|
return await measureNumericWidths(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertEditorItemSpacing(page: Page, mobile: boolean): Promise<void> {
|
||||||
|
const spacing = await page.evaluate(() => {
|
||||||
|
const modules = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ?? [];
|
||||||
|
const moduleLog = modules.find((entry): entry is TestLogModule => {
|
||||||
|
if (typeof entry !== "object" || entry === null) return false;
|
||||||
|
return typeof (entry as TestLogModule).statusLineRenderer?.render === "function";
|
||||||
|
});
|
||||||
|
if (!moduleLog?.statusLineRenderer?.render || !moduleLog.statusBarLabels) {
|
||||||
|
throw new Error("The LiveSync editor status renderer is unavailable.");
|
||||||
|
}
|
||||||
|
const original = moduleLog.statusBarLabels.value.parts;
|
||||||
|
const parts = [
|
||||||
|
{ key: "queue-replication", text: " 📥", value: 1 },
|
||||||
|
{ key: "queue-database", text: " 📄", value: 12 },
|
||||||
|
{ key: "queue-storage", text: " 💾", value: 3 },
|
||||||
|
];
|
||||||
|
const results: { selector: string; gapsEm: number[] }[] = [];
|
||||||
|
try {
|
||||||
|
moduleLog.statusLineRenderer.render(parts);
|
||||||
|
moduleLog.statusBarRenderer?.render?.(parts);
|
||||||
|
for (const selector of [".livesync-status-statusline", ".syncstatusbar"]) {
|
||||||
|
const root = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!root || root.getBoundingClientRect().width === 0) continue;
|
||||||
|
const firstItem = root.querySelector<HTMLElement>(`.livesync-stat-${parts[0].key}`);
|
||||||
|
if (!firstItem || Number.parseFloat(getComputedStyle(firstItem).marginInlineStart) !== 0) {
|
||||||
|
throw new Error("Inactive predecessors left a gap before the first active status item.");
|
||||||
|
}
|
||||||
|
const fontSize = Number.parseFloat(getComputedStyle(root).fontSize);
|
||||||
|
const gapsEm: number[] = [];
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const previous = root.querySelector<HTMLElement>(
|
||||||
|
`.livesync-stat-${parts[i - 1].key} .livesync-stat-value`
|
||||||
|
);
|
||||||
|
const next = root.querySelector<HTMLElement>(`.livesync-stat-${parts[i].key}`)?.firstChild;
|
||||||
|
if (!previous || next?.nodeType !== Node.TEXT_NODE)
|
||||||
|
throw new Error("A spacing fixture item is missing.");
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(next, next.textContent?.search(/\S/u) ?? 0);
|
||||||
|
range.setEnd(next, next.textContent?.length ?? 0);
|
||||||
|
const nextBox = range.getBoundingClientRect();
|
||||||
|
const previousBox = previous.getBoundingClientRect();
|
||||||
|
if (Math.abs(nextBox.top - previousBox.top) > 2)
|
||||||
|
throw new Error("The spacing fixture unexpectedly wrapped.");
|
||||||
|
gapsEm.push((nextBox.left - previousBox.right) / fontSize);
|
||||||
|
}
|
||||||
|
results.push({ selector, gapsEm });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
moduleLog.statusLineRenderer.render(original);
|
||||||
|
moduleLog.statusBarRenderer?.render?.(original);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
const editor = spacing.find((entry) => entry.selector === ".livesync-status-statusline");
|
||||||
|
failUnless(editor?.gapsEm.length === 2, "The editor status gaps could not be measured.");
|
||||||
|
for (const gap of editor.gapsEm) {
|
||||||
|
failUnless(gap >= 1 && gap <= 1.5, `Editor status items need a 1–1.5em gap; found ${gap.toFixed(3)}em.`);
|
||||||
|
}
|
||||||
|
if (!mobile) {
|
||||||
|
const statusBar = spacing.find((entry) => entry.selector === ".syncstatusbar");
|
||||||
|
failUnless(statusBar?.gapsEm.length === 2, "The status-bar gaps could not be measured.");
|
||||||
|
failUnless(
|
||||||
|
statusBar.gapsEm.every((gap) => gap < 1),
|
||||||
|
"The status-bar items unexpectedly acquired the wider editor gaps."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`${mobile ? "Mobile" : "Desktop"} editor item gaps: ${editor.gapsEm.map((gap) => gap.toFixed(3)).join(", ")}em.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertStatusBarItemSpacing(page: Page): Promise<void> {
|
||||||
|
const collapsedItems = await page.evaluate(() => {
|
||||||
|
const items = document.querySelectorAll<HTMLElement>(".syncstatusbar > .livesync-stat-item");
|
||||||
|
const collapsed: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.classList.contains("livesync-stat-inactive")) continue;
|
||||||
|
const prefix = item.firstChild;
|
||||||
|
if (prefix?.nodeType !== Node.TEXT_NODE || !prefix.textContent?.startsWith(" ")) continue;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(prefix, 0);
|
||||||
|
range.setEnd(prefix, 1);
|
||||||
|
if (range.getBoundingClientRect().width <= 0) collapsed.push(item.className);
|
||||||
|
}
|
||||||
|
return collapsed;
|
||||||
|
});
|
||||||
|
failUnless(
|
||||||
|
collapsedItems.length === 0,
|
||||||
|
`Status-bar item separators lost their visible spaces: ${collapsedItems.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertInactiveItemStyling(page: Page): Promise<void> {
|
||||||
|
const roots = [".livesync-status-statusline", ".syncstatusbar"];
|
||||||
|
for (const root of roots) {
|
||||||
|
const state = await page.locator(`${root} .livesync-stat-ff`).evaluate((element) => {
|
||||||
|
const item = element as HTMLElement;
|
||||||
|
return {
|
||||||
|
inactive: item.classList.contains("livesync-stat-inactive"),
|
||||||
|
hidden: item.hidden,
|
||||||
|
ariaHidden: item.getAttribute("aria-hidden"),
|
||||||
|
display: getComputedStyle(item).display,
|
||||||
|
text: item.textContent,
|
||||||
|
width: item
|
||||||
|
.querySelector<HTMLElement>(".livesync-stat-value")
|
||||||
|
?.style.getPropertyValue("--livesync-stat-width"),
|
||||||
|
precedesUploads: item.nextElementSibling?.classList.contains("livesync-stat-sent"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
failUnless(
|
||||||
|
state.inactive && !state.hidden && state.ariaHidden === "true",
|
||||||
|
`Inactive state was not exposed through the class in ${root}.`
|
||||||
|
);
|
||||||
|
failUnless(state.display === "none", `Inactive items were not hidden by the default CSS in ${root}.`);
|
||||||
|
failUnless(
|
||||||
|
state.text?.includes("1000") && state.width === "4ch" && state.precedesUploads,
|
||||||
|
`An inactive item lost its content, width, or position in ${root}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = await page.addStyleTag({
|
||||||
|
content: ".livesync-stat-item.livesync-stat-inactive { display: inline-block; }",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const root of roots) {
|
||||||
|
failUnless(
|
||||||
|
await page.locator(`${root} .livesync-stat-ff`).isVisible(),
|
||||||
|
`A CSS override could not reveal the inactive item in ${root}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await snippet.evaluate((element) => element.parentNode?.removeChild(element));
|
||||||
|
}
|
||||||
|
for (const root of roots) {
|
||||||
|
failUnless(
|
||||||
|
await page.locator(`${root} .livesync-stat-ff`).isHidden(),
|
||||||
|
`The default inactive style was not restored in ${root}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function measureStatusBarNetworkWidth(
|
async function measureStatusBarNetworkWidth(
|
||||||
page: Page,
|
page: Page,
|
||||||
expectedValue: string
|
expectedValue: string
|
||||||
): Promise<{ cssWidth: string; renderedWidth: number }> {
|
): Promise<{ cssWidth: string; renderedWidth: number }> {
|
||||||
const selector = ".syncstatusbar .livesync-stat-network .livesync-stat-value";
|
const selector = ".syncstatusbar .livesync-stat-network:not(.livesync-stat-inactive) .livesync-stat-value";
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
({ selector, expectedValue }) => {
|
({ selector, expectedValue }) => {
|
||||||
const value = document.querySelector<HTMLElement>(selector);
|
const value = document.querySelector<HTMLElement>(selector);
|
||||||
@@ -606,6 +750,13 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
|
|||||||
await injectReplicationStats(page, initialStats, 0, 0);
|
await injectReplicationStats(page, initialStats, 0, 0);
|
||||||
await waitForPartText(page, "arrived", "0");
|
await waitForPartText(page, "arrived", "0");
|
||||||
await waitForPartText(page, "ff", "1000");
|
await waitForPartText(page, "ff", "1000");
|
||||||
|
await assertStatusBarItemSpacing(page);
|
||||||
|
for (const root of [".livesync-status-statusline", ".syncstatusbar"]) {
|
||||||
|
failUnless(
|
||||||
|
(await page.locator(`${root} .livesync-stat-ff + .livesync-stat-sent`).count()) === 1,
|
||||||
|
`The comparison count did not appear immediately before uploads in ${root}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
const syncIcon = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
|
const syncIcon = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
|
||||||
failUnless(syncIcon?.includes("⏩"), "The fast-forward checking icon was not independent of arrivals.");
|
failUnless(syncIcon?.includes("⏩"), "The fast-forward checking icon was not independent of arrivals.");
|
||||||
|
|
||||||
@@ -617,11 +768,13 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
|
|||||||
if (remainingLinger > 0) await page.waitForTimeout(remainingLinger);
|
if (remainingLinger > 0) await page.waitForTimeout(remainingLinger);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
document.querySelector<HTMLElement>(".livesync-status-statusline .livesync-stat-ff")?.hidden ===
|
document
|
||||||
true,
|
.querySelector(".livesync-status-statusline .livesync-stat-ff")
|
||||||
|
?.classList.contains("livesync-stat-inactive") === true,
|
||||||
undefined,
|
undefined,
|
||||||
{ timeout: uiTimeoutMs }
|
{ timeout: uiTimeoutMs }
|
||||||
);
|
);
|
||||||
|
await assertInactiveItemStyling(page);
|
||||||
const iconAfterExpiry = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
|
const iconAfterExpiry = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
|
||||||
failUnless(
|
failUnless(
|
||||||
iconAfterExpiry?.includes("⏩"),
|
iconAfterExpiry?.includes("⏩"),
|
||||||
@@ -735,6 +888,7 @@ async function main(): Promise<void> {
|
|||||||
const binary = requireObsidianBinary();
|
const binary = requireObsidianBinary();
|
||||||
const cli = discoverObsidianCli();
|
const cli = discoverObsidianCli();
|
||||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||||
|
for (const mobile of [false, true]) {
|
||||||
const vault = await createTemporaryVault("obsidian-livesync-replication-status-");
|
const vault = await createTemporaryVault("obsidian-livesync-replication-status-");
|
||||||
let session: ObsidianLiveSyncSession | undefined;
|
let session: ObsidianLiveSyncSession | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -742,6 +896,13 @@ async function main(): Promise<void> {
|
|||||||
binary,
|
binary,
|
||||||
cliBinary: cli.binary,
|
cliBinary: cli.binary,
|
||||||
vault,
|
vault,
|
||||||
|
lifecycle: mobile
|
||||||
|
? {
|
||||||
|
beforePluginStart: async ({ remoteDebuggingPort }) => {
|
||||||
|
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||||
pluginData: {
|
pluginData: {
|
||||||
liveSync: false,
|
liveSync: false,
|
||||||
@@ -756,13 +917,42 @@ async function main(): Promise<void> {
|
|||||||
writeLogToTheFile: false,
|
writeLogToTheFile: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
failUnless(session.readiness.pluginId === "obsidian-livesync", "The LiveSync plug-in did not become ready.");
|
failUnless(
|
||||||
await runStatusScenario(session);
|
session.readiness.pluginId === "obsidian-livesync",
|
||||||
|
"The LiveSync plug-in did not become ready."
|
||||||
|
);
|
||||||
|
if (!mobile) await runStatusScenario(session);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
await installFixtureFile(page);
|
||||||
|
if (mobile) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const modules =
|
||||||
|
(globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ??
|
||||||
|
[];
|
||||||
|
const moduleLog = modules.find((entry): entry is TestLogModule => {
|
||||||
|
if (typeof entry !== "object" || entry === null) return false;
|
||||||
|
return typeof (entry as TestLogModule).adjustStatusDivPosition === "function";
|
||||||
|
});
|
||||||
|
moduleLog?.adjustStatusDivPosition?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.locator(".livesync-status").waitFor({ state: "attached", timeout: uiTimeoutMs });
|
||||||
|
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
|
||||||
|
await page.locator(".livesync-status").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`${String(error)}; spacing setup: ${JSON.stringify(await getStatusDiagnostics(page))}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assertEditorItemSpacing(page, mobile);
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (session) await session.app.stop();
|
if (session) await session.app.stop();
|
||||||
await vault.dispose();
|
await vault.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
console.error(error instanceof Error ? error.stack : error);
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
### Synchronisation
|
### Synchronisation
|
||||||
|
|
||||||
- We can see CouchDB change-history comparison progressing before new documents arrive, with a ⏩ status icon and a comparison count beside received items. An unchanged count disappears after three seconds.
|
- We can see CouchDB change-history comparison progressing before new documents arrive, with a ⏩ status icon and a comparison count before the upload count. An unchanged count disappears after three seconds.
|
||||||
- We can hide or style individual status items with CSS snippets using the `livesync-stat-` classes. Tabular, right-aligned numbers retain their width while visible, reducing movement as counts change.
|
- We can hide or style individual status items with CSS snippets using the `livesync-stat-` classes. Tabular, right-aligned numbers retain their width while visible, reducing movement as counts change. Wider gaps between editor status items make the counts easier to read. The `livesync-stat-inactive` class lets CSS snippets control how inactive items disappear.
|
||||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||||
|
|
||||||
## 1.0.40
|
## 1.0.40
|
||||||
|
|||||||
Reference in new issue
Block a user