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
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:
@@ -3,9 +3,9 @@ import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { appLink } from "../router/links";
|
||||
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
|
||||
// (#5173). There is no mail path, so the admin hands the link over.
|
||||
@@ -18,12 +18,11 @@ interface Account {
|
||||
}
|
||||
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const accounts = ref<Account[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const busy = ref("");
|
||||
const { busy, act } = useRowAction();
|
||||
// The reset link just made, and whose it is. Never retrievable again once dismissed.
|
||||
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 ${account.email}? Using it signs them out everywhere and unlinks their apps.`;
|
||||
if (!window.confirm(warning)) return;
|
||||
busy.value = account.id;
|
||||
try {
|
||||
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
|
||||
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't make a reset link."));
|
||||
} finally {
|
||||
busy.value = "";
|
||||
}
|
||||
await act(
|
||||
account.id,
|
||||
async () => {
|
||||
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
|
||||
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
|
||||
},
|
||||
"Couldn't make a reset link.",
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import BaseModal from "./BaseModal.vue";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import { isDesktop, desktop } from "../desktop/bridge";
|
||||
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
|
||||
// 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 show = ref(false);
|
||||
const working = ref(false);
|
||||
const { busy: working, act } = useAction();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return;
|
||||
@@ -33,17 +33,12 @@ function dismiss() {
|
||||
}
|
||||
|
||||
async function integrate() {
|
||||
working.value = true;
|
||||
try {
|
||||
await act(async () => {
|
||||
await desktop.integrate();
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
show.value = false;
|
||||
ui.showToast("Inkwell added to your applications menu.");
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't add to applications."));
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}, "Couldn't add to applications.");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
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.
|
||||
// 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.
|
||||
const ui = useUiStore();
|
||||
const status = ref<IntegrationStatus | null>(null);
|
||||
const busy = ref(false);
|
||||
const { busy, act } = useAction();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isDesktop()) return;
|
||||
@@ -24,17 +24,12 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
async function toggle() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await act(async () => {
|
||||
status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate();
|
||||
ui.showToast(
|
||||
status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.",
|
||||
);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't update the menu entry."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}, "Couldn't update the menu entry.");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { repo } from "../adapters";
|
||||
import { errorMessage } from "../api/errors";
|
||||
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;
|
||||
// on the desktop the core builds it from the local store with no server, saves it in
|
||||
// Downloads, and says exactly where.
|
||||
const ui = useUiStore();
|
||||
const busy = ref(false);
|
||||
const { busy, act } = useAction();
|
||||
|
||||
async function run() {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await act(async () => {
|
||||
const savedTo = await repo.notes.exportAll();
|
||||
if (savedTo) ui.showToast(`Exported to ${savedTo}`);
|
||||
} catch (err) {
|
||||
ui.showToast(errorMessage(err, "Export failed."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}, "Export failed.");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import BaseInput from "./BaseInput.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
|
||||
// the instance's; anyone can share with it, and a note shared with it reaches whoever
|
||||
@@ -23,7 +23,6 @@ interface Group {
|
||||
members: Person[];
|
||||
}
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
const groups = ref<Group[]>([]);
|
||||
const accounts = ref<Person[]>([]);
|
||||
@@ -31,7 +30,7 @@ const loading = ref(true);
|
||||
const error = ref("");
|
||||
const name = ref("");
|
||||
const creating = ref(false);
|
||||
const busy = ref("");
|
||||
const { busy, act } = useRowAction();
|
||||
// Which group's "Add someone" picker holds which account id.
|
||||
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);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!name.value.trim()) return;
|
||||
creating.value = true;
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from "./Icon.vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
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,
|
||||
// or a Google Keep Takeout archive) and import its notes. Additive — never
|
||||
@@ -13,7 +13,7 @@ const notes = useNotesStore();
|
||||
const labels = useLabelsStore();
|
||||
const ui = useUiStore();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const busy = ref(false);
|
||||
const { busy, act } = useAction();
|
||||
|
||||
function pick() {
|
||||
if (!busy.value) inputRef.value?.click();
|
||||
@@ -24,18 +24,13 @@ async function onFile(e: Event) {
|
||||
const file = input.files?.[0];
|
||||
input.value = ""; // reset so picking the same file again re-fires change
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await act(async () => {
|
||||
const res = await notes.importNotes(file);
|
||||
await labels.load(); // surface any labels the import created
|
||||
const noun = res.imported === 1 ? "note" : "notes";
|
||||
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
|
||||
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
|
||||
} catch (err) {
|
||||
ui.showToast(errorMessage(err, "Import failed."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}, "Import failed.");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { appLink } from "../router/links";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import BaseInput from "./BaseInput.vue";
|
||||
import OneTimeLink from "./OneTimeLink.vue";
|
||||
import { toastOnFailure } from "../composables/useAction";
|
||||
|
||||
// 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,
|
||||
@@ -29,7 +29,6 @@ const LIFETIMES = [
|
||||
{ days: 30, label: "30 days" },
|
||||
];
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
const invites = ref<Invite[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -42,7 +41,7 @@ const freshLink = ref("");
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
await toastOnFailure(async () => {
|
||||
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, "Couldn't load invites.");
|
||||
@@ -75,9 +74,7 @@ async function revoke(invite: Invite) {
|
||||
try {
|
||||
const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
|
||||
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't revoke that invite."));
|
||||
}
|
||||
}, "Couldn't revoke that invite.");
|
||||
}
|
||||
|
||||
function who(invite: Invite): string {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
import { repo } from "../adapters";
|
||||
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 desktop and Android apps sync with a device bearer token issued here), its
|
||||
@@ -96,7 +97,7 @@ const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const changing = ref(false);
|
||||
const passwordError = ref("");
|
||||
const signingOut = ref(false);
|
||||
const { busy: signingOut, act } = useAction();
|
||||
|
||||
function unlinkedNote(n: number): string {
|
||||
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.",
|
||||
);
|
||||
if (!ok) return;
|
||||
signingOut.value = true;
|
||||
try {
|
||||
await act(async () => {
|
||||
const unlinked = await session.signOutElsewhere();
|
||||
ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`);
|
||||
await devices.load();
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't sign out everywhere else."));
|
||||
} finally {
|
||||
signingOut.value = false;
|
||||
}
|
||||
}, "Couldn't sign out everywhere else.");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
import { toastOnFailure } from "../composables/useAction";
|
||||
|
||||
// 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
|
||||
@@ -319,7 +320,7 @@ async function disconnect() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await toastOnFailure(async () => {
|
||||
const result = await syncBridge.unlink();
|
||||
status.value = result.status;
|
||||
linkedAs.value = "";
|
||||
@@ -331,9 +332,7 @@ async function disconnect() {
|
||||
? "Disconnected, and this device is signed out on the server."
|
||||
: "Disconnected. This device now works offline only.",
|
||||
);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't disconnect."));
|
||||
}
|
||||
}, "Couldn't disconnect.");
|
||||
}
|
||||
|
||||
// A background cycle finished — redraw from it, so this screen is never staler
|
||||
|
||||
Reference in New Issue
Block a user