Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/RestoreView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ export class RestoreDialog extends Modal {
public plugin: DiffZipBackupPlugin
) {
super(app);
this.containerEl.addClass("diffzip-restore-modal-container");
}

component?: ReturnType<typeof mount>;

async onOpen() {
const toc = await this.plugin.loadTOC();

const containerEl = this.modalEl;
const containerEl = this.contentEl;
containerEl.empty();
this.titleEl.setText("Restore (Revision Selector)");
this.setTitle("Restore (Revision Selector)");

this.component = mount(RestoreRevisionDialogComponent, {
target: containerEl,
Expand Down
32 changes: 32 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,38 @@
padding-bottom: 3px;
}

.is-mobile .diffzip-restore-modal-container {
box-sizing: border-box;
/* The bottom inset can already include the keyboard; reserve their union once. */
max-height: calc(
100vh - max(var(--keyboard-height, 0px), var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))
);
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
padding-bottom: 0;
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}

.is-mobile .diffzip-restore-modal-container > .modal {
max-height: 100%;
min-height: 0;
}

.is-mobile .diffzip-restore-modal-container .diffzip-restore-search {
width: 100%;
min-width: 0;
}

.is-mobile .diffzip-restore-modal-container .diffzip-restore-search input {
flex: 1;
min-width: 0;
}

.is-mobile .diffzip-restore-modal-container .diffzip-restore-table .col-rev {
width: auto;
min-width: 0;
}

.diffzip-restore-controls {
justify-content: space-between;
flex-wrap: wrap;
Expand Down
2 changes: 1 addition & 1 deletion test/e2e-obsidian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This local-only suite installs the built DiffZip plug-in into an isolated vault and profile through `@vrtmrz/obsidian-test-session`. It is not part of the default CI gate.

The restore-confirmation scenario seeds 60 restore entries across three ZIPs and 24 local files represented by mirror deletion records. It invokes the real normal-restore and delete-missing confirmation paths, verifies their rendered Markdown summaries and boundary paths, expands the long file lists, and checks explicit cancellation and Escape dismissal in a real Obsidian Modal. At a fixed phone viewport, the public test-session assertions require the title, Close control, and action row to respect supplied safe-area insets, prevent horizontal overflow in the expanded content and action row, and require 44 CSS-pixel Close, Restore, and Cancel touch targets. It does not use scripted `UiInteractions` responses.
The restore-confirmation scenario seeds 60 restore entries across three ZIPs and 24 local files represented by mirror deletion records. It first opens the revision selector through the real command, expands its folders, and checks that the dialogue, search field, and scrolled action row fit within supplied safe-area insets in portrait, landscape, and with a simulated keyboard height. It compares the content spacing against Obsidian's native `.modal-content` padding and checks that the standard title and Close control remain inside the safe area. It also checks horizontal overflow and 44 CSS-pixel Close, Restore, and Cancel touch targets. It then invokes the real normal-restore and delete-missing confirmation paths, verifies their rendered Markdown summaries and boundary paths, expands the long file lists, and checks explicit cancellation and Escape dismissal in a real Obsidian Modal. At a fixed phone viewport, the public test-session assertions require the title, Close control, and action row to respect supplied safe-area insets, prevent horizontal overflow in the expanded content and action row, and require 44 CSS-pixel Close, Restore, and Cancel touch targets. It does not use scripted `UiInteractions` responses.

The mirror-delete-semantics scenario opens the real revision selector and verifies that 'All and delete extra' enables deletion without enabling new-files-only mode. It verifies that the selected historical revision, rather than the current TOC state, decides whether a file is restored or deleted. Missing and unreadable archives, missing ZIP entries, and Vault write failures must reject the restore and preserve deletion candidates. Planning and execution each hold one wake-lock lease, while the confirmation wait holds none. The final large-fixture pass observes the extraction plan without reading fixture ZIPs, verifies that no deletion record is sent for extraction, and verifies that all 24 applicable local files are removed through the active Vault storage accessor.

Expand Down
154 changes: 154 additions & 0 deletions test/e2e-obsidian/restore-confirmation.mts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,166 @@ async function leavePhoneReview(page: Page): Promise<void> {
"--safe-area-inset-right",
"--safe-area-inset-bottom",
"--safe-area-inset-left",
"--keyboard-height",
]) {
document.body.style.removeProperty(property);
}
});
await page.setViewportSize({ width: 1280, height: 960 });
}

async function verifyRevisionSelectorLayout(testSession: DiffZipTestSession): Promise<void> {
const scenarios = [
{
label: "portrait",
viewport: { width: 390, height: 844 },
safeAreaInsets: PHONE_SAFE_AREA_INSETS,
keyboardHeight: 0,
closeWithNativeButton: true,
},
{
label: "landscape",
viewport: { width: 844, height: 390 },
safeAreaInsets: { top: 0, right: 47, bottom: 21, left: 47 },
keyboardHeight: 0,
closeWithNativeButton: false,
},
{
label: "portrait with keyboard",
viewport: { width: 390, height: 844 },
safeAreaInsets: PHONE_SAFE_AREA_INSETS,
keyboardHeight: 320,
closeWithNativeButton: false,
},
];
await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => {
await enterPhoneReview(page);
try {
for (const scenario of scenarios) {
const safeAreaInsets = {
...scenario.safeAreaInsets,
bottom: Math.max(scenario.safeAreaInsets.bottom, scenario.keyboardHeight),
};
await page.setViewportSize(scenario.viewport);
await page.evaluate(({ safeAreaInsets, keyboardHeight }) => {
for (const [edge, inset] of Object.entries(safeAreaInsets)) {
document.body.style.setProperty(`--safe-area-inset-${edge}`, `${inset}px`);
}
document.body.style.setProperty("--keyboard-height", `${keyboardHeight}px`);
}, scenario);
const executed = await page.evaluate(() => {
const app = (
globalThis as typeof globalThis & {
app?: { commands?: { executeCommandById(id: string): boolean } };
}
).app;
return app?.commands?.executeCommandById("diffzip:a-find-from-backups") ?? false;
});
if (!executed) throw new Error("Restore from Backup command was unavailable");
const modal = page
.locator(".modal-container")
.filter({ has: page.locator(".diffzip-sync-dialog") })
.last();
await modal.getByLabel("Restore Mode").waitFor({ state: "visible", timeout: 10_000 });
await modal.evaluate((element) =>
Promise.all(
element
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined))
)
);
await modal.getByRole("button", { name: "Expand All", exact: true }).click();
const dialogue = modal.locator(".modal");
await dialogue.evaluate((element) => {
element.scrollTop = 0;
});
await assertLocatorWithinSafeArea(page, dialogue, {
label: `${scenario.label} revision selector`,
safeAreaInsets,
});
await assertLocatorWithinSafeArea(page, modal.locator(".diffzip-restore-search"), {
label: `${scenario.label} revision search`,
safeAreaInsets,
});
await assertNativeContentSpacing(dialogue, modal.locator(".diffzip-restore-search"), "top");
await assertNoHorizontalOverflow(page, dialogue, { label: `${scenario.label} revision selector` });
await assertNoHorizontalOverflow(page, modal.locator(".diffzip-sync-table-wrap"), {
label: `${scenario.label} revision list`,
});
const actions = modal.locator(".diffzip-sync-footer");
await actions.scrollIntoViewIfNeeded();
await assertLocatorWithinSafeArea(page, actions, {
label: `${scenario.label} revision actions`,
safeAreaInsets,
});
await assertNativeContentSpacing(dialogue, actions, "bottom");
await modal.getByText("Restore (Revision Selector)", { exact: true }).waitFor({ state: "visible" });
for (const selector of [".modal-title", ".modal-close-button"]) {
await assertLocatorWithinSafeArea(page, modal.locator(selector), {
label: `${scenario.label} revision selector ${selector}`,
safeAreaInsets,
});
}
await assertLocatorHasMinimumTouchTarget(page, modal.locator(".modal-close-button"), {
label: `${scenario.label} revision selector close button`,
});
for (const name of ["Restore", "Cancel"]) {
await assertLocatorHasMinimumTouchTarget(page, modal.getByRole("button", { name, exact: true }), {
label: `${scenario.label} ${name} button`,
});
}
if (scenario.closeWithNativeButton) {
await modal.locator(".modal-close-button").click();
} else {
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
}
await modal.waitFor({ state: "detached", timeout: 10_000 });
}
} finally {
await leavePhoneReview(page);
}
});
}

async function assertNativeContentSpacing(
dialogue: Locator,
control: Locator,
verticalEdge: "top" | "bottom"
): Promise<void> {
const padding = await dialogue.evaluate((element) => {
const reference = document.createElement("div");
reference.className = "modal-content";
Object.assign(reference.style, { position: "absolute", visibility: "hidden", pointerEvents: "none" });
element.appendChild(reference);
const style = getComputedStyle(reference);
const result = {
left: Number.parseFloat(style.paddingLeft),
right: Number.parseFloat(style.paddingRight),
top: Number.parseFloat(style.paddingTop),
bottom: Number.parseFloat(style.paddingBottom),
};
reference.remove();
return result;
});
const frame = await dialogue.boundingBox();
const box = await control.boundingBox();
if (!frame || !box) throw new Error("Revision selector spacing could not be measured");
const actual = {
left: box.x - frame.x,
right: frame.x + frame.width - box.x - box.width,
top: box.y - frame.y,
bottom: frame.y + frame.height - box.y - box.height,
};
for (const edge of ["left", "right", verticalEdge] as const) {
if (actual[edge] + 1 < padding[edge]) {
throw new Error(
`Revision selector ${edge} spacing is ${actual[edge]}px; native content requires ${padding[edge]}px`
);
}
}
}

async function assertLargeConfirmationLayout(page: Page, modal: Locator, deleteMissing: boolean): Promise<void> {
const dialogue = modal.locator(".modal");
const title = dialogue.locator(".modal-title");
Expand Down Expand Up @@ -188,6 +341,7 @@ async function main(): Promise<void> {
let testSession: DiffZipTestSession | undefined;
try {
testSession = await startDiffZipTestSession({ restorePlan: "large" });
await verifyRevisionSelectorLayout(testSession);
await verifyCancellation(testSession, { deleteMissing: false, dismissWithEscape: false });
await verifyCancellation(testSession, { deleteMissing: true, dismissWithEscape: true });
console.log("DiffZip large restore and mirror confirmations passed in real Obsidian");
Expand Down
6 changes: 6 additions & 0 deletions updates.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Updates

## Unreleased

### Fixed

- The restore revision selector uses Obsidian's standard content spacing and keeps its search field and action buttons within mobile safe areas when the file list is expanded or the keyboard is shown.

## 0.1.12

### Maintenance
Expand Down
Loading