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
35 changes: 34 additions & 1 deletion docs/technical_plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ of Geode testable without a running app.
- [What The Plugin Owns](#what-the-plugin-owns)
- [The First Sync Dialog](#the-first-sync-dialog)
- [The Mass Change Dialog](#the-mass-change-dialog)
- [Toasts](#toasts)
- [Guards](#guards)
- [Writing Files Safely](#writing-files-safely)
- [Deleting Files](#deleting-files)
Expand Down Expand Up @@ -47,6 +48,7 @@ needing a running Obsidian.
| State store | Reading and writing `state.json` |
| Storage | Dispatching signed requests through `requestUrl` |
| Log sink | Appending to a capped file in the plugin's data folder |
| Toaster | Putting a decided toast on screen, and taking the sticky one down |

Pulled content is written through the low level data adapter rather than the Vault API, because a
path pulled down for the first time has no `TFile` for the Vault API to operate on.
Expand All @@ -56,7 +58,7 @@ path pulled down for the first time has no `TFile` for the Vault API to operate
- Loading settings, the device identity, and whether this vault has synced before
- Registering the vault event listeners that mark local work pending
- Ticking the scheduler and starting a pass when one is due
- The status bar, the log view, and the commands
- The status bar, the log view, the toasts, and the commands
- Translating a failed pass into what the scheduler should do about it

That last one is the whole of the contract between two modules that otherwise know nothing about
Expand Down Expand Up @@ -134,6 +136,37 @@ the dialog opens again, and says so: a second identical looking prompt with no e
a bug, and someone who has already clicked through one is exactly the person who will click through
the next without reading it.

### Toasts

The status bar is a cloud icon and a tooltip, which is enough to answer "what is it doing" and
nothing like enough to say something you have to act on. Toasts are the other half, and every one
geode raises comes from a single table in `notify/notify.ts`, so the wording, the duration, and the
silences are all pinned by one test rather than scattered across the plugin class.

| Occasion | Says | Stays |
| --------------------------------- | -------------------------------------------------------- | ----- |
| Automatic sync halted | The reason, since nothing will happen until you act | Until dismissed |
| A large change is waiting on you | That nothing has synced, for whoever dismissed the dialog | 10s |
| Any pass failed | The reason | 10s |
| Conflict copies were made | How many, and that your copy is beside the remote one | 10s |
| A pass applied changes | How many | 5s |
| A manual pass found nothing to do | That you were already up to date | 5s |
| Syncing recovered | That it is working again | 5s |
| Paused, resumed, settings saved | That it happened | 5s |

One thing stays silent: an automatic pass that applied nothing. That is the product working, it
happens every few minutes forever, and a notice you cannot stop is not information. `log.ts` drops
those lines for the same reason.

Precedence within a pass is worst news first. A halt outranks the failure it arrived as, a mass
change outranks the halt, and a conflict outranks the change count it came with, because the count
is the part you would have guessed.

The halt toast is the only sticky one, and it is held so the next thing geode says can take it
down. A "stopped syncing" notice still on screen after you have fixed the credentials is worse than
no notice at all, and a sticky toast outlives the plugin that raised it, so disabling geode retires
it too.

### Guards

Three pieces of state the plugin holds, each preventing a specific failure.
Expand Down
4 changes: 4 additions & 0 deletions docs/technical_sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ A conflict is a path that changed on both sides to different content. Neither ed
discarded: the local edit is renamed to a conflict copy and pushed, and the remote version claims
the original path.

It is also the one thing a successful pass does that nobody would find on their own, so a pass that
made any says so in a [toast](technical_plugin.md#toasts). An edit kept under a name you never
learn about is not an edit that survived.

The copy's name carries the time and the device, because on a three device vault "whose" is the
question actually being asked. It uses no spaces, is lowercase throughout, and separates fields with
underscores while hyphens live inside them, so a name parses unambiguously from the right even when
Expand Down
86 changes: 68 additions & 18 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { DEVICE_ID_KEY, deviceIdFrom, deviceSuffixFrom } from "./device/device";
import { createLogSink } from "./log/adapter";
import { createLogBus, createLogger, type LogBus, type Logger, type LogSink } from "./log/log";
import { GeodeLogView, LOG_VIEW_TYPE } from "./log/view";
import { DEFAULT_PASS, type Pass, toastFor } from "./notify/notify";
import { createToaster, type Toaster } from "./notify/obsidian";
import { type Actions, GeodeOnboardingModal } from "./onboarding/modal";
import { type RemoteRead, readRemote, type SyncReport } from "./onboarding/onboarding";
import {
Expand Down Expand Up @@ -65,9 +67,9 @@ type AppWithSetting = App & {
};
};

// PassOutcome pairs what the scheduler needs from a pass with what a UI watching one needs, since
// neither answer contains the other: "stop" is not a message, and a message is not a policy.
type PassOutcome = { report: SyncReport; result: PassResult };
// PassOutcome pairs what the scheduler needs from a pass with what everything watching one needs,
// since neither answer contains the other: "stop" is not a message, and a message is not a policy.
type PassOutcome = { pass: Pass; result: PassResult };

// SyncStatus is the state the status bar item reflects.
type SyncStatus = "idle" | "syncing" | "error" | "paused";
Expand Down Expand Up @@ -121,6 +123,16 @@ function passResultFor(fault: SyncFault): PassResult {
return "retry";
}

// reportFor returns what a caller watching a pass is told about it, which is the part of a pass the
// first sync dialog can render.
function reportFor(pass: Pass): SyncReport {
if (pass.ok) {
return { ok: true, changeCount: pass.changes };
}

return { ok: false, message: pass.message };
}

// tooltipFor returns the status bar hover text for status. detail is folded into the error case.
function tooltipFor(status: SyncStatus, detail: string): string {
if (status === "syncing") {
Expand Down Expand Up @@ -160,6 +172,9 @@ export default class GeodePlugin extends Plugin {
// once per session before trusting the compare and swap. Reset on save, so a new provider
// re-verifies.
private conditionalWritesVerified = false;
// toaster owns everything geode says out loud, including the one notice that waits to be
// dismissed: a halt still on screen after the halt cleared is a lie.
private toaster: Toaster = createToaster();

async onload() {
await this.loadSettings();
Expand Down Expand Up @@ -230,6 +245,9 @@ export default class GeodePlugin extends Plugin {
},
});
this.register(() => this.app.workspace.detachLeavesOfType(LOG_VIEW_TYPE));
// A sticky toast outlives the plugin that raised it, and a disabled geode has no business
// still saying it has stopped syncing.
this.register(() => this.toaster.dismissSticky());

this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass("geode-status-bar", "mod-clickable");
Expand Down Expand Up @@ -381,9 +399,11 @@ export default class GeodePlugin extends Plugin {
this.setSyncStatus(this.restingStatus(), "");
if (paused) {
this.logger.info("automatic sync paused on this device");
this.toaster.show(toastFor({ kind: "paused" }));
return;
}
this.logger.info("automatic sync resumed on this device");
this.toaster.show(toastFor({ kind: "resumed" }));
}

// setSyncStatus updates the status bar icon and tooltip to reflect status.
Expand Down Expand Up @@ -417,35 +437,38 @@ export default class GeodePlugin extends Plugin {
confirmed: MassChange | null = null,
): Promise<SyncReport> {
if (this.schedule.syncing) {
return { ok: false, message: "a sync is already running" };
// No status change: a pass is on screen already saying it is running, and this one never
// started, so the toast is the whole of the answer.
const message = "a sync is already running";
this.toaster.show(toastFor({ kind: "pass", pass: { ...DEFAULT_PASS, message } }));
return { ok: false, message };
}
if (!hasConnectionConfig(this.settings)) {
this.logger.warn("sync: storage isn't configured yet");
this.setSyncStatus("error", "storage isn't configured yet");
return { ok: false, message: "storage isn't configured yet" };
return this.refuse("storage isn't configured yet");
}
// The storage client already refuses an unusable prefix; checking here is what makes the
// refusal legible, rather than reporting it as a failed conditional write probe.
const badPrefix = prefixError(this.settings.prefix);
if (badPrefix !== "") {
this.logger.warn(`sync: ${badPrefix}`);
this.setSyncStatus("error", badPrefix);
return { ok: false, message: badPrefix };
return this.refuse(badPrefix);
}
const dir = this.manifest.dir;
if (dir === undefined) {
this.logger.error("sync: no plugin data directory available");
this.setSyncStatus("error", "no plugin data directory available");
return { ok: false, message: "no plugin data directory available" };
return this.refuse("no plugin data directory available");
}

// Read before the pass starts, since finishing one is what resets the streak it followed.
const recovered = this.schedule.failures > 0;
if (trigger === "manual") {
this.schedule = noteResumed(this.schedule);
}
this.schedule = notePassStarted(this.schedule);
this.setSyncStatus("syncing", "");
let outcome: PassOutcome = {
report: { ok: false, message: "unexpected error" },
pass: { ...DEFAULT_PASS, message: "unexpected error" },
result: "retry",
};
try {
Expand All @@ -457,12 +480,27 @@ export default class GeodePlugin extends Plugin {
}
this.logger.error(`sync: ${message}`);
this.setSyncStatus("error", message);
outcome = { report: { ok: false, message }, result: "retry" };
outcome = { pass: { ...DEFAULT_PASS, message }, result: "retry" };
} finally {
this.schedule = notePassFinished(this.schedule, outcome.result, Date.now());
}

return outcome.report;
// A halted pass and a blocked one both end automatic sync, but only one of them is the user's
// to answer, so the dialog's own case is never reported as a halt.
const stopped = outcome.result === "stop" && !outcome.pass.blocked;
const pass: Pass = { ...outcome.pass, manual: trigger === "manual", recovered, stopped };
this.toaster.show(toastFor({ kind: "pass", pass }));

return reportFor(pass);
}

// refuse reports a pass that never started. The status bar carries it, and so does a toast, since
// a refusal nobody sees is indistinguishable from a sync that silently never runs.
private refuse(message: string): SyncReport {
this.setSyncStatus("error", message);
this.toaster.show(toastFor({ kind: "pass", pass: { ...DEFAULT_PASS, message } }));

return { ok: false, message };
}

// runSync does the work of syncNow, split out so the guard and status bar bookkeeping stay
Expand All @@ -477,7 +515,7 @@ export default class GeodePlugin extends Plugin {
const message = "secret access key not found; open settings to reconfigure";
this.logger.error(`sync: secret access key not found for ID "${this.settings.secretId}"`);
this.setSyncStatus("error", message);
return { report: { ok: false, message }, result: "stop" };
return { pass: { ...DEFAULT_PASS, message }, result: "stop" };
}

const storage = createS3Client(this.settings, secretAccessKey, obsidianTransport);
Expand All @@ -487,7 +525,7 @@ export default class GeodePlugin extends Plugin {
if (!probe.ok) {
this.logger.error(`sync: conditional write check failed: ${probe.message}`);
this.setSyncStatus("error", probe.message);
return { report: { ok: false, message: probe.message }, result: "stop" };
return { pass: { ...DEFAULT_PASS, message: probe.message }, result: "stop" };
}
this.conditionalWritesVerified = true;
this.logger.info("sync: conditional write support verified");
Expand Down Expand Up @@ -523,7 +561,10 @@ export default class GeodePlugin extends Plugin {
void this.syncNow("manual", confirmed);
}).open();

return { report: { ok: false, message: outcome.message }, result: "stop" };
return {
pass: { ...DEFAULT_PASS, blocked: true, message: outcome.message },
result: "stop",
};
}
if (!outcome.ok) {
// A failed pass can still have made progress worth keeping: completed work is recorded so
Expand All @@ -538,7 +579,7 @@ export default class GeodePlugin extends Plugin {
this.setSyncStatus("error", outcome.message);

return {
report: { ok: false, message: outcome.message },
pass: { ...DEFAULT_PASS, message: outcome.message },
result: passResultFor(outcome.fault),
};
}
Expand All @@ -552,7 +593,15 @@ export default class GeodePlugin extends Plugin {
}
this.setSyncStatus(this.restingStatus(), "");

return { report: { ok: true, changeCount: outcome.changeCount }, result: "ok" };
return {
pass: {
...DEFAULT_PASS,
changes: outcome.changeCount,
conflicts: outcome.conflictCount,
ok: true,
},
result: "ok",
};
}

// loadDeviceId returns this device's identity, minting one on first run and treating an unusable
Expand Down Expand Up @@ -607,6 +656,7 @@ export default class GeodePlugin extends Plugin {
this.schedule = noteResumed(this.schedule);
await this.loadSyncedBefore();
this.logger.info("settings saved");
this.toaster.show(toastFor({ kind: "settingsSaved" }));
// Saving a connection is the moment someone has said what they want and nothing has happened
// yet, which is the only moment the first sync dialog has anything to offer.
this.offerOnboarding();
Expand Down
Loading