Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
eee4772
Close settings before handing off to sync approval or setup
callumalpass Oct 1, 2026
08a3296
Fence mutable TFile identities across asynchronous path checks
callumalpass Oct 1, 2026
ac39e60
Expire mirror-write echoes in linear time during bulk downloads
callumalpass Oct 1, 2026
49740fd
Protect the physical vault across replica changes and reauthorization…
callumalpass Oct 1, 2026
a3d19ff
Show checking sync rather than a stale success during plan inspection
callumalpass Oct 1, 2026
bcd6118
Classify mobile HTTP failures even when native JSON getters throw
callumalpass Oct 1, 2026
5dcbbcd
Name the trash destination when reviewing disconnect cleanup
callumalpass Oct 1, 2026
b75c789
Reconcile vault events that arrive during the initial yielded record …
callumalpass Oct 1, 2026
aef9038
Keep disconnect unavailable until its decision or cleanup has completed
callumalpass Oct 1, 2026
b779696
Merge remote-tracking branch 'origin/main' into polish/ux
callumalpass Oct 1, 2026
5a145ee
Merge remote-tracking branch 'origin/main' into polish/merge
callumalpass Oct 1, 2026
e61a4ab
Merge round 3 integration and keep the consolidated Obsidian mock
callumalpass Oct 1, 2026
d50c652
Fence renewal commits against connection retirement in other windows
callumalpass Oct 1, 2026
d40cd43
Close staged upload sources after failed or cancelled object transfers
callumalpass Oct 1, 2026
9656d43
Merge integration round 3 while retaining cross-window reliability co…
callumalpass Oct 1, 2026
4f0c2ec
Restore popout text selections using their owning window's DOM constr…
callumalpass Oct 1, 2026
ede3b3f
Fence record-cache installation against concurrent folder invalidation
callumalpass Oct 1, 2026
68ed9bf
Hold conflict decisions during active sync without hiding their compa…
callumalpass Oct 1, 2026
f2c29b4
Verify conflict decisions remain held during sync in isolated visual …
callumalpass Oct 1, 2026
d8ebdb6
Fence coalesced schema loads against newer invalidations
callumalpass Oct 1, 2026
9d9e42f
Skip acknowledged multipart bytes without allocating discarded output…
callumalpass Oct 1, 2026
4da964a
Honor pause requests while sync inspection finishes
callumalpass Oct 1, 2026
33e3237
Keep async comparison results and errors bound to their reviewed plan
callumalpass Oct 1, 2026
d2c9449
Partition recovery-copy names by replica so concurrent devices cannot…
callumalpass Oct 1, 2026
50b1a77
Merge branch 'polish/merge' into integration/polish-4
callumalpass Oct 1, 2026
ca9a047
Merge branch 'polish/perf' into integration/polish-4
callumalpass Oct 1, 2026
9634e51
Merge branch 'polish/ux' into integration/polish-4
callumalpass Oct 1, 2026
28e606b
Merge branch 'polish/lab-e2e' into integration/polish-4
callumalpass Oct 1, 2026
42daebc
Rebuild main.js for integration round 4
callumalpass Oct 1, 2026
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
2 changes: 1 addition & 1 deletion docs/sync-ux-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ node scripts/test-sync-layout.cjs after-layout

`MDBASE_UX_PLAYWRIGHT` can be omitted if Playwright is already resolvable. It is not a plugin runtime dependency.

The audit captures 23 states at 1100px and 390px, in dark and light themes: healthy, long excluded-folder scope, local file issue, offline, sign-in, copied device, paused, unexpected failure, progress, first-sync review, deletion-burst review, rebuild, unresolved conflict (both versions present, local absent, or hosted absent), pinned conflict history, enrollment, and local upload, plus five sync problems viewed from Issues (where mobile needs an in-workspace indicator). Desktop sidebars are collapsed for realistic narrow panes. Assertions reject horizontal overflow, hidden sync problems when viewing another destination, and primary/dismiss targets below 44px in narrow panes. Long, unbroken paths are intentional. `*-checks.json` records the measured bounds; the command exits nonzero on a regression.
The audit captures 24 states at 1100px and 390px, in dark and light themes: healthy, long excluded-folder scope, local file issue, offline, sign-in, copied device, paused, unexpected failure, progress, first-sync review, deletion-burst review, rebuild, unresolved conflict (both versions present, local absent, hosted absent, or active sync), pinned conflict history, enrollment, and local upload, plus five sync problems viewed from Issues (where mobile needs an in-workspace indicator). Desktop sidebars are collapsed for realistic narrow panes. Assertions reject horizontal overflow, hidden sync problems when viewing another destination, conflict decisions enabled while sync is active, and primary/dismiss targets below 44px in narrow panes. Long, unbroken paths are intentional. `*-checks.json` records the measured bounds; the command exits nonzero on a regression.

Review the PNGs as well as the assertions. Diff panels may scroll horizontally within their own surface; the workspace itself must not. Stop the isolated instance after testing and discard its app/profile/vault. Injected profile state is ephemeral and must never be saved as real credentials.
94 changes: 47 additions & 47 deletions main.js

Large diffs are not rendered by default.

53 changes: 37 additions & 16 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export default class MdbasePlugin extends Plugin {
private syncScheduler: SyncScheduler | null = null;
private recordCache: Map<string, CollectionRecord> | null = null;
private recordCacheSettings = "";
private recordCacheEpoch = 0;
private recordList: CollectionRecord[] | null = null;
private recordLoadPromise: Promise<CollectionRecord[]> | null = null;
private readonly dirtyRecordPaths = new Set<string>();
Expand Down Expand Up @@ -770,7 +771,9 @@ export default class MdbasePlugin extends Plugin {

private markRecordChanged(path: string): void {
this.validation.changed(path);
if (!this.recordCache) return;
// A yielded initial scan can already have read this path. Keep its change
// even before the completed cache is installed, then reconcile it below.
if (!this.recordCache && !this.recordLoadPromise) return;
this.dirtyRecordPaths.add(normalizePath(path));
this.recordList = null;
}
Expand All @@ -787,28 +790,33 @@ export default class MdbasePlugin extends Plugin {
}

private async readRecords(): Promise<CollectionRecord[]> {
const epoch = this.recordCacheEpoch;
const loaded = await this.getConfigAndTypes();
if (!loaded) return [];
const settingsKey = JSON.stringify(loaded.config.settings);
if (!this.recordCache || settingsKey !== this.recordCacheSettings) {
this.dirtyRecordPaths.clear();
const records = await readCollectionRecords(this.app.vault, loaded.config);
if (epoch !== this.recordCacheEpoch) return this.readRecords();
this.recordCache = new Map(records.map((record) => [record.path, record]));
this.recordCacheSettings = settingsKey;
this.recordList = null;
} else if (this.dirtyRecordPaths.size) {
}
const cache = this.recordCache;
if (this.dirtyRecordPaths.size) {
const paths = [...this.dirtyRecordPaths];
this.dirtyRecordPaths.clear();
for (const path of paths) {
this.recordCache.delete(path);
cache.delete(path);
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile) || file.extension !== "md" || isExcluded(path, loaded.config)) continue;
const parsed = parseFrontmatter(await this.app.vault.cachedRead(file));
if (!parsed.error) this.recordCache.set(path, { path, frontmatter: parsed.frontmatter });
if (!parsed.error) cache.set(path, { path, frontmatter: parsed.frontmatter });
}
this.recordList = null;
}
this.recordList ??= [...this.recordCache.values()].sort((a, b) => a.path.localeCompare(b.path));
if (epoch !== this.recordCacheEpoch) return this.readRecords();
this.recordList ??= [...cache.values()].sort((a, b) => a.path.localeCompare(b.path));
return this.recordList;
}

Expand All @@ -824,6 +832,7 @@ export default class MdbasePlugin extends Plugin {
validationIssues,
localChangeObserved: state.localChangeObserved,
paused: state.paused,
busy: state.busy,
reviewChanges: safety && !safety.safe ? state.preview?.plan.actions.length ?? 0 : 0,
});
}
Expand Down Expand Up @@ -947,20 +956,30 @@ export default class MdbasePlugin extends Plugin {
if (this.schemaCache) return this.schemaCache;
if (this.schemaLoadPromise) return this.schemaLoadPromise;

this.schemaLoadPromise = (async () => {
const config = await loadMdbaseConfig(this.app.vault);
if (!config) return null;
const types = await loadTypeDefinitions(this.app.vault, config);
const contracts = await loadContractDefinitions(this.app.vault, config);
return { config, types, contracts };
})();
// Store the fenced promise itself: all coalesced callers must reject a
// result invalidated while its asynchronous config/type reads were running.
const loading: Promise<LoadedSchema | null> = Promise.resolve().then(async () => {
try {
const config = await loadMdbaseConfig(this.app.vault);
const loaded = config ? {
config,
types: await loadTypeDefinitions(this.app.vault, config),
contracts: await loadContractDefinitions(this.app.vault, config),
} : null;
if (this.schemaLoadPromise !== loading) return this.getConfigAndTypes();
if (loaded) this.schemaCache = loaded;
return loaded;
} catch (error) {
if (this.schemaLoadPromise !== loading) return this.getConfigAndTypes();
throw error;
}
});
this.schemaLoadPromise = loading;

try {
const loaded = await this.schemaLoadPromise;
if (loaded) this.schemaCache = loaded;
return loaded;
return await loading;
} finally {
this.schemaLoadPromise = null;
if (this.schemaLoadPromise === loading) this.schemaLoadPromise = null;
}
}

Expand Down Expand Up @@ -1002,6 +1021,8 @@ export default class MdbasePlugin extends Plugin {
for (const path of paths) this.observeLocalMirrorChange(path);
// A folder event need not be accompanied by child-file events. The old
// descendant paths are no longer a valid incremental record/schema cache.
// An older in-flight read must not reinstall those invalidated paths.
this.recordCacheEpoch++;
this.recordCache = null;
this.recordList = null;
this.dirtyRecordPaths.clear();
Expand Down
7 changes: 5 additions & 2 deletions scripts/test-sync-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fs.mkdirSync(shots, {recursive:true});
app.workspace.leftSplit.collapse();app.workspace.rightSplit.collapse();
window.open=()=>null;
});
const states=['healthy','scope','local-issue','offline','auth','copied','paused','internal','progress','first-sync','deletions','rebuild','conflict','conflict-local-absent','conflict-hosted-absent','history','enrollment','upload','away-offline','away-auth','away-copied','away-paused','away-internal'];
const states=['healthy','scope','local-issue','offline','auth','copied','paused','internal','progress','first-sync','deletions','rebuild','conflict','conflict-busy','conflict-local-absent','conflict-hosted-absent','history','enrollment','upload','away-offline','away-auth','away-copied','away-paused','away-internal'];
const results=[];
for(const state of states) {
await page.evaluate(state=>{
Expand Down Expand Up @@ -63,6 +63,7 @@ fs.mkdirSync(shots, {recursive:true});
p.sync.update({status:{state:'attention',conflicts:[{entity:'record',object_id:'record',decision_id:'decision',path:longPath,message:'Both versions changed. Choose which version to sync.'}],local_issues:[]}});
view.sync.conflictComparisons.set('record:decision',{entity:'record',objectId:'record',decisionId:'decision',local:state==='conflict-local-absent'?{state:'absent'}:{state:'exact',document:'Line from this device'},remote:state==='conflict-hosted-absent'?{state:'absent'}:{state:'exact',document:'Line from another device'}});
}
if(state==='conflict-busy')p.sync.update({busy:true});
if(state==='history') {
const at=new Date().toISOString();
window.fixtureRuns=[{id:'fixture-conflict',collectionId:'ux-fixture',startedAt:at,finishedAt:at,outcome:'event',files:[],summary:'Kept both versions of '+longPath.split('/').pop(),message:"This device's version was saved as "+longPath.replace('.md',' (local conflict copy).md'),path:longPath,tone:'attention',needsAcknowledgement:true}];
Expand All @@ -80,8 +81,9 @@ fs.mkdirSync(shots, {recursive:true});
const checks=await page.locator('.mdbase-sync-document, .mdbase-issues-document').evaluate(el=>{
const content=el.closest('.mdbase-workspace-content');
const buttons=[...el.querySelectorAll('.mod-cta, .mod-warning, .mdbase-conflict-row button, .mdbase-activity-row > button')].map(b=>({text:b.textContent,height:b.getBoundingClientRect().height}));
const choices=[...el.querySelectorAll('button[data-focus-key^="resolve-"]')].map(b=>({text:b.textContent,disabled:b.disabled}));
const topbar=el.closest('.mdbase-workspace').querySelector('.mdbase-topbar');
return {topbarOverflow:topbar.scrollWidth>topbar.clientWidth+1,tabStatus:topbar.querySelector('.mdbase-nav-status')?.textContent,paneWidth:content.clientWidth,headingWidth:el.querySelector('.mdbase-sync-heading')?.getBoundingClientRect().width,scrollWidth:content.scrollWidth,overflow:content.scrollWidth>content.clientWidth+1,buttons};
return {topbarOverflow:topbar.scrollWidth>topbar.clientWidth+1,tabStatus:topbar.querySelector('.mdbase-nav-status')?.textContent,paneWidth:content.clientWidth,headingWidth:el.querySelector('.mdbase-sync-heading')?.getBoundingClientRect().width,scrollWidth:content.scrollWidth,overflow:content.scrollWidth>content.clientWidth+1,buttons,choices};
});
const box=await page.locator('.mdbase-workspace').boundingBox();
const cdp=await page.context().newCDPSession(page);
Expand All @@ -91,6 +93,7 @@ fs.mkdirSync(shots, {recursive:true});
if(checks.overflow)failures.push('Horizontal overflow');
if(checks.topbarOverflow)failures.push('Destination bar overflow');
if(state.startsWith('away-')&&!checks.tabStatus)failures.push('Problem hidden outside Sync');
if(state==='conflict-busy'&&(checks.choices.length!==3||checks.choices.some(b=>!b.disabled)))failures.push('Conflict decisions enabled during sync');
if(width===390&&checks.headingWidth!=null&&checks.headingWidth<checks.paneWidth-30)failures.push('Narrow heading squeezed by the action');
if(width===390&&checks.buttons.some(b=>b.height<43.5))failures.push('Primary/dismiss target below 44px');
results.push({state,width,theme,...checks,failures});
Expand Down
Loading
Loading