DRY pass #2, batch 5, F16: one way an action reports its failure (#5372)
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 8s
CI & Build / Web typecheck and unit tests (push) Failing after 11s
CI & Build / Python tests (push) Successful in 19s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 44s
CI & Build / integration (push) Successful in 1m32s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m11s
Android / Build the server image (push) Successful in 1s

composables/useAction.ts: toastOnFailure runs an action and toasts the
server's reason or a fallback; useAction adds the busy flag a button
waits on; useRowAction keeps the id of the row whose action is running.
Import, export, the menu entry, the integration prompt, sign out
elsewhere, the account reset link, the group actions (whose local act()
it replaces), invite revoke and sync disconnect each wrote that
try/catch/finally out.

Kept: AccountView's device revoke. It shows a fixed message rather than
the server's reason, and moving it would change the text. ShareDialog
shows its errors inline, not in a toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:44:43 -04:00
co-authored by Claude Opus 5.5
parent fe1b61fa4d
commit ebbe4a678a
10 changed files with 85 additions and 81 deletions
+10 -12
View File
@@ -3,9 +3,9 @@ import { onMounted, ref } from "vue";
import { api } from "../api/client"; import { api } from "../api/client";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links"; import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue"; import OneTimeLink from "./OneTimeLink.vue";
import { useRowAction } from "../composables/useAction";
// Admin: the accounts on this instance, and a password reset link for any of them // Admin: the accounts on this instance, and a password reset link for any of them
// (#5173). There is no mail path, so the admin hands the link over. // (#5173). There is no mail path, so the admin hands the link over.
@@ -18,12 +18,11 @@ interface Account {
} }
const session = useSessionStore(); const session = useSessionStore();
const ui = useUiStore();
const accounts = ref<Account[]>([]); const accounts = ref<Account[]>([]);
const loading = ref(true); const loading = ref(true);
const error = ref(""); const error = ref("");
const busy = ref(""); const { busy, act } = useRowAction();
// The reset link just made, and whose it is. Never retrievable again once dismissed. // The reset link just made, and whose it is. Never retrievable again once dismissed.
const fresh = ref<{ link: string; email: string } | null>(null); const fresh = ref<{ link: string; email: string } | null>(null);
@@ -44,15 +43,14 @@ async function resetLink(account: Account) {
? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included." ? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included."
: `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`; : `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`;
if (!window.confirm(warning)) return; if (!window.confirm(warning)) return;
busy.value = account.id; await act(
try { account.id,
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {}); async () => {
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email }; const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
} catch (e) { fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
ui.showToast(errorMessage(e, "Couldn't make a reset link.")); },
} finally { "Couldn't make a reset link.",
busy.value = ""; );
}
} }
onMounted(() => { onMounted(() => {
@@ -4,7 +4,7 @@ import BaseModal from "./BaseModal.vue";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import { isDesktop, desktop } from "../desktop/bridge"; import { isDesktop, desktop } from "../desktop/bridge";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { errorMessage } from "../api/errors"; import { useAction } from "../composables/useAction";
// First-run prompt (Linux AppImage only) offering to add Inkwell to the // First-run prompt (Linux AppImage only) offering to add Inkwell to the
// applications menu so it behaves like an installed app instead of a loose file. // applications menu so it behaves like an installed app instead of a loose file.
@@ -15,7 +15,7 @@ const DISMISS_KEY = "desktop.integration.prompted";
const ui = useUiStore(); const ui = useUiStore();
const show = ref(false); const show = ref(false);
const working = ref(false); const { busy: working, act } = useAction();
onMounted(async () => { onMounted(async () => {
if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return; if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return;
@@ -33,17 +33,12 @@ function dismiss() {
} }
async function integrate() { async function integrate() {
working.value = true; await act(async () => {
try {
await desktop.integrate(); await desktop.integrate();
localStorage.setItem(DISMISS_KEY, "1"); localStorage.setItem(DISMISS_KEY, "1");
show.value = false; show.value = false;
ui.showToast("Inkwell added to your applications menu."); ui.showToast("Inkwell added to your applications menu.");
} catch (e) { }, "Couldn't add to applications.");
ui.showToast(errorMessage(e, "Couldn't add to applications."));
} finally {
working.value = false;
}
} }
</script> </script>
+4 -9
View File
@@ -2,8 +2,8 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge"; import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction";
// The applications-menu entry for a Linux AppImage: add it, or take it away again. // The applications-menu entry for a Linux AppImage: add it, or take it away again.
// Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build. // Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build.
@@ -12,7 +12,7 @@ import { useUiStore } from "../stores/ui";
// first-run prompt promised ("undo any time") with no way to reach it. // first-run prompt promised ("undo any time") with no way to reach it.
const ui = useUiStore(); const ui = useUiStore();
const status = ref<IntegrationStatus | null>(null); const status = ref<IntegrationStatus | null>(null);
const busy = ref(false); const { busy, act } = useAction();
onMounted(async () => { onMounted(async () => {
if (!isDesktop()) return; if (!isDesktop()) return;
@@ -24,17 +24,12 @@ onMounted(async () => {
}); });
async function toggle() { async function toggle() {
busy.value = true; await act(async () => {
try {
status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate(); status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate();
ui.showToast( ui.showToast(
status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.", status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.",
); );
} catch (e) { }, "Couldn't update the menu entry.");
ui.showToast(errorMessage(e, "Couldn't update the menu entry."));
} finally {
busy.value = false;
}
} }
</script> </script>
+4 -10
View File
@@ -1,27 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction";
// Sidebar Export. In a browser the server streams the zip and the browser saves it; // Sidebar Export. In a browser the server streams the zip and the browser saves it;
// on the desktop the core builds it from the local store with no server, saves it in // on the desktop the core builds it from the local store with no server, saves it in
// Downloads, and says exactly where. // Downloads, and says exactly where.
const ui = useUiStore(); const ui = useUiStore();
const busy = ref(false); const { busy, act } = useAction();
async function run() { async function run() {
if (busy.value) return; if (busy.value) return;
busy.value = true; await act(async () => {
try {
const savedTo = await repo.notes.exportAll(); const savedTo = await repo.notes.exportAll();
if (savedTo) ui.showToast(`Exported to ${savedTo}`); if (savedTo) ui.showToast(`Exported to ${savedTo}`);
} catch (err) { }, "Export failed.");
ui.showToast(errorMessage(err, "Export failed."));
} finally {
busy.value = false;
}
} }
</script> </script>
+2 -14
View File
@@ -2,10 +2,10 @@
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { api } from "../api/client"; import { api } from "../api/client";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue"; import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { useRowAction } from "../composables/useAction";
// Admin: groups of people a note can be shared with in one go (#5177). A group is // Admin: groups of people a note can be shared with in one go (#5177). A group is
// the instance's; anyone can share with it, and a note shared with it reaches whoever // the instance's; anyone can share with it, and a note shared with it reaches whoever
@@ -23,7 +23,6 @@ interface Group {
members: Person[]; members: Person[];
} }
const ui = useUiStore();
const groups = ref<Group[]>([]); const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]); const accounts = ref<Person[]>([]);
@@ -31,7 +30,7 @@ const loading = ref(true);
const error = ref(""); const error = ref("");
const name = ref(""); const name = ref("");
const creating = ref(false); const creating = ref(false);
const busy = ref(""); const { busy, act } = useRowAction();
// Which group's "Add someone" picker holds which account id. // Which group's "Add someone" picker holds which account id.
const picks = ref<Record<string, string>>({}); const picks = ref<Record<string, string>>({});
@@ -64,17 +63,6 @@ function replace(updated: Group) {
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName); groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
} }
async function act(id: string, fn: () => Promise<void>, fallback: string) {
busy.value = id;
try {
await fn();
} catch (e) {
ui.showToast(errorMessage(e, fallback));
} finally {
busy.value = "";
}
}
async function create() { async function create() {
if (!name.value.trim()) return; if (!name.value.trim()) return;
creating.value = true; creating.value = true;
+4 -9
View File
@@ -4,7 +4,7 @@ import Icon from "./Icon.vue";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { errorMessage } from "../api/errors"; import { useAction } from "../composables/useAction";
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip, // Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip,
// or a Google Keep Takeout archive) and import its notes. Additive — never // or a Google Keep Takeout archive) and import its notes. Additive — never
@@ -13,7 +13,7 @@ const notes = useNotesStore();
const labels = useLabelsStore(); const labels = useLabelsStore();
const ui = useUiStore(); const ui = useUiStore();
const inputRef = ref<HTMLInputElement | null>(null); const inputRef = ref<HTMLInputElement | null>(null);
const busy = ref(false); const { busy, act } = useAction();
function pick() { function pick() {
if (!busy.value) inputRef.value?.click(); if (!busy.value) inputRef.value?.click();
@@ -24,18 +24,13 @@ async function onFile(e: Event) {
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ""; // reset so picking the same file again re-fires change input.value = ""; // reset so picking the same file again re-fires change
if (!file) return; if (!file) return;
busy.value = true; await act(async () => {
try {
const res = await notes.importNotes(file); const res = await notes.importNotes(file);
await labels.load(); // surface any labels the import created await labels.load(); // surface any labels the import created
const noun = res.imported === 1 ? "note" : "notes"; const noun = res.imported === 1 ? "note" : "notes";
const tail = res.skipped ? ` (${res.skipped} skipped)` : ""; const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`); ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
} catch (err) { }, "Import failed.");
ui.showToast(errorMessage(err, "Import failed."));
} finally {
busy.value = false;
}
} }
</script> </script>
+3 -6
View File
@@ -2,11 +2,11 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { api } from "../api/client"; import { api } from "../api/client";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links"; import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue"; import BaseInput from "./BaseInput.vue";
import OneTimeLink from "./OneTimeLink.vue"; import OneTimeLink from "./OneTimeLink.vue";
import { toastOnFailure } from "../composables/useAction";
// Admin: invite one person to register while registration stays closed (#5172). // Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once, // The server keeps only a hash of each invite's token, so the link is shown once,
@@ -29,7 +29,6 @@ const LIFETIMES = [
{ days: 30, label: "30 days" }, { days: 30, label: "30 days" },
]; ];
const ui = useUiStore();
const invites = ref<Invite[]>([]); const invites = ref<Invite[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -42,7 +41,7 @@ const freshLink = ref("");
async function load() { async function load() {
error.value = ""; error.value = "";
try { await toastOnFailure(async () => {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites; invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
} catch (e) { } catch (e) {
error.value = errorMessage(e, "Couldn't load invites."); error.value = errorMessage(e, "Couldn't load invites.");
@@ -75,9 +74,7 @@ async function revoke(invite: Invite) {
try { try {
const updated = await api.del<Invite>(`/api/invites/${invite.id}`); const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i)); invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
} catch (e) { }, "Couldn't revoke that invite.");
ui.showToast(errorMessage(e, "Couldn't revoke that invite."));
}
} }
function who(invite: Invite): string { function who(invite: Invite): string {
+47
View File
@@ -0,0 +1,47 @@
import { ref } from "vue";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
/**
* Run `work`, and say so in a toast if it fails: the server's own reason when it
* gave one, else `fallback`. The one way a button's action reports a failure it
* has no better place to show.
*/
export async function toastOnFailure(work: () => Promise<unknown>, fallback: string): Promise<void> {
try {
await work();
} catch (e) {
useUiStore().showToast(errorMessage(e, fallback));
}
}
/** A single action and whether it is running, for the button that starts it. */
export function useAction() {
const busy = ref(false);
async function act(work: () => Promise<unknown>, fallback: string): Promise<void> {
busy.value = true;
try {
await toastOnFailure(work, fallback);
} finally {
busy.value = false;
}
}
return { busy, act };
}
/**
* The same for a list whose rows each carry an action: `busy` is the id of the row
* whose action is running, or "" when none is, so only that row's buttons wait.
*/
export function useRowAction() {
const busy = ref("");
async function act(id: string, work: () => Promise<unknown>, fallback: string): Promise<void> {
busy.value = id;
try {
await toastOnFailure(work, fallback);
} finally {
busy.value = "";
}
}
return { busy, act };
}
+4 -8
View File
@@ -13,6 +13,7 @@ import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime"; import { formatDateTime } from "../notes/datetime";
import { repo } from "../adapters"; import { repo } from "../adapters";
import type { StorageUse } from "../adapters/repo"; import type { StorageUse } from "../adapters/repo";
import { useAction } from "../composables/useAction";
// The signed-in person's own account (not admin): the native clients linked to it // The signed-in person's own account (not admin): the native clients linked to it
// (the desktop and Android apps sync with a device bearer token issued here), its // (the desktop and Android apps sync with a device bearer token issued here), its
@@ -96,7 +97,7 @@ const currentPassword = ref("");
const newPassword = ref(""); const newPassword = ref("");
const changing = ref(false); const changing = ref(false);
const passwordError = ref(""); const passwordError = ref("");
const signingOut = ref(false); const { busy: signingOut, act } = useAction();
function unlinkedNote(n: number): string { function unlinkedNote(n: number): string {
if (n === 0) return ""; if (n === 0) return "";
@@ -124,16 +125,11 @@ async function signOutElsewhere() {
"Sign out of every other browser and unlink every device? Each app will need to sign in again.", "Sign out of every other browser and unlink every device? Each app will need to sign in again.",
); );
if (!ok) return; if (!ok) return;
signingOut.value = true; await act(async () => {
try {
const unlinked = await session.signOutElsewhere(); const unlinked = await session.signOutElsewhere();
ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`); ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`);
await devices.load(); await devices.load();
} catch (e) { }, "Couldn't sign out everywhere else.");
ui.showToast(errorMessage(e, "Couldn't sign out everywhere else."));
} finally {
signingOut.value = false;
}
} }
onMounted(() => { onMounted(() => {
+3 -4
View File
@@ -24,6 +24,7 @@ import {
} from "../desktop/bridge"; } from "../desktop/bridge";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime"; import { formatDateTime } from "../notes/datetime";
import { toastOnFailure } from "../composables/useAction";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting // Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
// state, not an incomplete setup — the app is local-first and fully usable having // state, not an incomplete setup — the app is local-first and fully usable having
@@ -319,7 +320,7 @@ async function disconnect() {
) { ) {
return; return;
} }
try { await toastOnFailure(async () => {
const result = await syncBridge.unlink(); const result = await syncBridge.unlink();
status.value = result.status; status.value = result.status;
linkedAs.value = ""; linkedAs.value = "";
@@ -331,9 +332,7 @@ async function disconnect() {
? "Disconnected, and this device is signed out on the server." ? "Disconnected, and this device is signed out on the server."
: "Disconnected. This device now works offline only.", : "Disconnected. This device now works offline only.",
); );
} catch (e) { }, "Couldn't disconnect.");
ui.showToast(errorMessage(e, "Couldn't disconnect."));
}
} }
// A background cycle finished — redraw from it, so this screen is never staler // A background cycle finished — redraw from it, so this screen is never staler