feat(updater): download and prepare desktop updates in background
This commit is contained in:
77 files changed
+10536
-814
No files matched your search
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, ref, shallowRef, toRaw, useSlots, watch, defineAsyncComponent, type Component, type CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
@@ -11365,6 +11366,7 @@ function openGridSnapshot() {
|
||||
};
|
||||
gridSnapshotOpen.value = true;
|
||||
}
|
||||
useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.value || isSaving.value ? t("updates.preparationDrafts") : undefined));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -40,6 +40,7 @@ const props = defineProps<{
|
||||
showDriverStore: boolean;
|
||||
showSettingsPage: boolean;
|
||||
checkingUpdates: boolean;
|
||||
updateVersion?: string;
|
||||
hasUpdateAvailable: boolean;
|
||||
isDownloadingUpdate: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -75,15 +76,12 @@ const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
|
||||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
|
||||
const checkingUpdates = computed(() => props.checkingUpdates);
|
||||
const updateTooltip = computed(() => {
|
||||
if (props.isDownloadingUpdate) return t("updates.downloading", { progress: props.downloadProgress ?? 0 });
|
||||
if (props.updateReady) return t("updates.restartRequiredTooltip");
|
||||
if (props.updateReadyToInstall) return t("updates.updateReadyTooltip");
|
||||
if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip");
|
||||
if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" });
|
||||
return t("updates.check");
|
||||
});
|
||||
// ToolbarUpdateIcon takes a 0..1 fraction, with null meaning the download size is unknown.
|
||||
const updateDownloadProgress = computed(() => (props.downloadProgress == null ? null : Math.min(1, Math.max(0, props.downloadProgress / 100))));
|
||||
|
||||
const sqlLibrarySaveFeedbackActive = ref(false);
|
||||
const SQL_LIBRARY_BOOKMARK_PATH = "M10 2 L10 10 L13 7 L16 10 L16 2";
|
||||
const SQL_LIBRARY_CHECK_PATH = "M9 9.5 L9 9.5 L11 11.5 L15 7.5 L15 7.5";
|
||||
@@ -199,7 +197,7 @@ const collapsibleRightItemDefs = computed(() => {
|
||||
label: t("updates.check"),
|
||||
icon: CloudDownload,
|
||||
action: () => emit("check-updates"),
|
||||
disabled: checkingUpdates.value,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
@@ -612,10 +610,9 @@ const toolbarStyle = computed(() => {
|
||||
<template v-if="toolbarItems.checkUpdates">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :disabled="checkingUpdates" @click="emit('check-updates')">
|
||||
<ToolbarUpdateIcon :loading="checkingUpdates" :downloading="isDownloadingUpdate" :progress="updateDownloadProgress" />
|
||||
<span v-if="updateReady || updateReadyToInstall" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-background" />
|
||||
<span v-else-if="hasUpdateAvailable && !isDownloadingUpdate" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" @click="emit('check-updates')">
|
||||
<ToolbarUpdateIcon />
|
||||
<span v-if="hasUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ updateTooltip }}</TooltipContent>
|
||||
|
||||
@@ -1,82 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Cloud, CloudDownload } from "@lucide/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
/** Whether an update is downloading; takes precedence over `loading`. */
|
||||
downloading?: boolean;
|
||||
/** Download completion in 0..1, or null when the backend reports no total. */
|
||||
progress?: number | null;
|
||||
}>();
|
||||
|
||||
const progressRingStyle = computed(() => {
|
||||
// Without a known total the ring cannot grow, so render a rotating partial arc
|
||||
// instead of an invisible 0% one.
|
||||
const percent = props.progress == null ? 25 : Math.round(Math.min(1, Math.max(0, props.progress)) * 100);
|
||||
return {
|
||||
background: `conic-gradient(var(--primary) ${percent}%, transparent ${percent}%)`,
|
||||
};
|
||||
});
|
||||
import { CloudDownload } from "@lucide/vue";
|
||||
// Background checking/downloading deliberately never animates the toolbar.
|
||||
defineProps<{ loading?: boolean; downloading?: boolean; progress?: number | null }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span data-toolbar-update-icon class="relative block h-4 w-4" :class="{ 'toolbar-update-icon--loading': loading }" aria-hidden="true">
|
||||
<span v-if="downloading" data-toolbar-update-progress class="toolbar-update-progress absolute inset-0 rounded-full" :class="{ 'toolbar-update-progress--indeterminate': progress == null }" :style="progressRingStyle" />
|
||||
<CloudDownload v-else-if="!loading" data-toolbar-update-idle class="toolbar-update-idle h-4 w-4" />
|
||||
<template v-else>
|
||||
<Cloud data-toolbar-update-cloud class="toolbar-update-cloud absolute inset-0 h-4 w-4" />
|
||||
<Cloud data-toolbar-update-scan class="toolbar-update-scan absolute inset-0 h-4 w-4" />
|
||||
</template>
|
||||
<span data-toolbar-update-icon class="relative block h-4 w-4" aria-hidden="true">
|
||||
<CloudDownload data-toolbar-update-idle class="h-4 w-4" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-update-cloud {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.toolbar-update-scan {
|
||||
color: #000;
|
||||
stroke-dasharray: 7 36;
|
||||
stroke-dashoffset: 0;
|
||||
animation: toolbar-update-outline-scan 1100ms linear infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar-update-scan {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toolbar-update-progress {
|
||||
/* Same 1.75px ring the toolbar previously drew inline for background downloads. */
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
|
||||
}
|
||||
|
||||
.toolbar-update-progress--indeterminate {
|
||||
animation: toolbar-update-progress-spin 1100ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes toolbar-update-outline-scan {
|
||||
to {
|
||||
stroke-dashoffset: -43;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toolbar-update-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toolbar-update-scan {
|
||||
animation: none;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.toolbar-update-progress--indeterminate {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+18
-5
@@ -14,6 +14,7 @@ const mountedApps: App[] = [];
|
||||
interface DialogState {
|
||||
open: boolean;
|
||||
portableMode: boolean;
|
||||
releaseNotes: string;
|
||||
manualUpdateOnly: boolean;
|
||||
isDownloadingUpdate: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -32,6 +33,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
|
||||
const state = reactive<DialogState>({
|
||||
open: true,
|
||||
portableMode: false,
|
||||
releaseNotes: "",
|
||||
manualUpdateOnly: false,
|
||||
isDownloadingUpdate: false,
|
||||
downloadProgress: 0,
|
||||
@@ -76,7 +78,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
|
||||
manual_update_only: state.manualUpdateOnly,
|
||||
release_name: "DBX v0.5.61",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.61",
|
||||
release_notes: "",
|
||||
release_notes: state.releaseNotes,
|
||||
},
|
||||
updateCheckMessage: "",
|
||||
checkingUpdates: false,
|
||||
@@ -110,7 +112,7 @@ function buttonWithText(text: string): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
function downloadButton(): HTMLButtonElement | undefined {
|
||||
return buttonWithText("Download in Background");
|
||||
return buttonWithText("Retry Download");
|
||||
}
|
||||
|
||||
function cancelDownloadButton(): HTMLButtonElement | undefined {
|
||||
@@ -118,7 +120,7 @@ function cancelDownloadButton(): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
function installDownloadedButton(): HTMLButtonElement | undefined {
|
||||
return buttonWithText("Exit & Update");
|
||||
return buttonWithText("Restart & Update");
|
||||
}
|
||||
|
||||
async function pressEscape() {
|
||||
@@ -338,10 +340,10 @@ describe("UpdateDialog ignore version", () => {
|
||||
expect(ignoreVersion).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides the ignore button once an update has been downloaded", async () => {
|
||||
it("allows ignoring a downloaded update", async () => {
|
||||
await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
|
||||
|
||||
expect(buttonWithText("Ignore this version")).toBeUndefined();
|
||||
expect(buttonWithText("Ignore this version")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables the ignore button while the setting is being persisted", async () => {
|
||||
@@ -350,3 +352,14 @@ describe("UpdateDialog ignore version", () => {
|
||||
expect(buttonWithText("Ignore this version")?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateDialog release notes safety", () => {
|
||||
it("renders remote HTML as text and never creates unsafe links or image requests", async () => {
|
||||
await mountDialog(0, { releaseNotes: '<img src="https://example.com/tracker" onerror="alert(1)"><script>alert(1)</script> [bad](javascript:alert)  [safe](https://example.com/release)' });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('a[href="https://example.com/release"]')).not.toBeNull();
|
||||
});
|
||||
expect(document.body.querySelector("script, img")).toBeNull();
|
||||
expect(Array.from(document.body.querySelectorAll("a")).every((anchor) => anchor.href.startsWith("https://"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
downloadProgress: number | null;
|
||||
updateDownloaded: boolean;
|
||||
isInstallingUpdate: boolean;
|
||||
isPreparingUpdate?: boolean;
|
||||
updateReady: boolean;
|
||||
isIgnoringUpdate: boolean;
|
||||
activeTaskCount: number;
|
||||
@@ -45,7 +46,7 @@ const renderedNotes = ref("");
|
||||
// download survives the dialog closing, so closing it never cancels the download.
|
||||
const isCloseBlocked = computed(() => props.isInstallingUpdate);
|
||||
const blocksImplicitDismiss = computed(() => props.isInstallingUpdate);
|
||||
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.updateDownloaded && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
|
||||
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
|
||||
|
||||
function handleCancel() {
|
||||
handleOpenChange(false);
|
||||
@@ -66,7 +67,7 @@ function handleReleaseNotesClick(event: MouseEvent) {
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const url = anchor.getAttribute("href");
|
||||
if (!url) return;
|
||||
if (!url || !/^https?:\/\//i.test(url)) return;
|
||||
if (isTauriRuntime()) {
|
||||
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
||||
} else {
|
||||
@@ -82,7 +83,19 @@ watch(
|
||||
return;
|
||||
}
|
||||
const { Marked } = await import("marked");
|
||||
const marked = new Marked({ breaks: true, gfm: true });
|
||||
const escapeHtml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
const marked = new Marked({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, tokens }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return /^https?:\/\//i.test(href) ? `<a href="${escapeHtml(href)}" rel="noopener noreferrer">${text}</a>` : text;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text),
|
||||
},
|
||||
});
|
||||
renderedNotes.value = marked.parse(notes) as string;
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -117,7 +130,7 @@ watch(
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-muted-foreground">
|
||||
<p v-else-if="!checkingUpdates" class="text-muted-foreground">
|
||||
{{ updateCheckMessage || t("updates.upToDate", { version: updateInfo?.current_version || "" }) }}
|
||||
</p>
|
||||
<div
|
||||
@@ -142,10 +155,13 @@ watch(
|
||||
<span>{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="checkingUpdates" class="text-sm text-muted-foreground">{{ t("updates.checking") }}</p>
|
||||
<p v-if="updateDownloaded && !isInstallingUpdate" class="text-sm">{{ t("updates.downloadedReady", { version: updateInfo?.latest_version }) }}</p>
|
||||
<p v-if="updateCheckFailed && updateInfo?.update_available" role="alert" class="text-sm text-destructive">{{ updateCheckMessage }}</p>
|
||||
<DialogFooter class="relative min-w-0 sm:justify-end">
|
||||
<div v-if="updateCheckFailed && !isDownloadingUpdate && !updateReady" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
|
||||
<div v-if="!updateReady && !isInstallingUpdate && !updateDownloaded && (updateInfo?.update_available || updateCheckFailed)" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
|
||||
<span class="text-xs text-muted-foreground">{{ t("updates.source") }}</span>
|
||||
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
|
||||
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates || isIgnoringUpdate" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
|
||||
<SelectTrigger class="h-8 w-[150px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -165,10 +181,10 @@ watch(
|
||||
<Button variant="outline" class="shrink-0" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
|
||||
</div>
|
||||
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
|
||||
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
|
||||
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('restart')">{{ t("updates.restart") }}</Button>
|
||||
<Button v-else-if="isInstallingUpdate" class="shrink-0" disabled>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("updates.installing") }}
|
||||
{{ t(isPreparingUpdate ? "updates.preparing" : "updates.installing") }}
|
||||
</Button>
|
||||
<template v-else-if="isDownloadingUpdate">
|
||||
<Button variant="ghost" class="shrink-0" @click="emit('cancel-download')">{{ t("updates.cancelDownload") }}</Button>
|
||||
@@ -177,8 +193,8 @@ watch(
|
||||
{{ t("updates.downloading", { progress: downloadProgress ?? 0 }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
|
||||
<Button v-else class="shrink-0" @click="emit('download-in-background')">{{ t("updates.downloadInBackground") }}</Button>
|
||||
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('install-downloaded')">{{ t("updates.restartAndUpdate") }}</Button>
|
||||
<Button v-else-if="!checkingUpdates" class="shrink-0" :disabled="isIgnoringUpdate" @click="emit('download-in-background')">{{ t("updates.retryDownload") }}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -1,89 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createApp, h } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ToolbarUpdateIcon", () => {
|
||||
it("uses cloud download while idle and a cloud outline scan while checking", async () => {
|
||||
const state = reactive({ loading: false });
|
||||
describe("silent toolbar update icon", () => {
|
||||
it.each([{ loading: false }, { loading: true }, { downloading: true, progress: 0.4 }, { downloading: true, progress: null }])("keeps the ordinary icon for %o", (props) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: state.loading }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const app = createApp({ render: () => h(ToolbarUpdateIcon, props) });
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
|
||||
state.loading = true;
|
||||
await nextTick();
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-scan]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows a progress ring while downloading, growing with progress", async () => {
|
||||
const state = reactive({ downloading: true, progress: 0.42 });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: false, downloading: state.downloading, progress: state.progress }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const ring = container.querySelector<HTMLElement>("[data-toolbar-update-progress]");
|
||||
expect(ring).not.toBeNull();
|
||||
expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(false);
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 42%, transparent 42%)");
|
||||
// The idle and checking glyphs stay hidden behind the ring.
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
|
||||
state.progress = 1;
|
||||
await nextTick();
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 100%, transparent 100%)");
|
||||
|
||||
state.downloading = false;
|
||||
await nextTick();
|
||||
expect(container.querySelector("[data-toolbar-update-progress]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a rotating indeterminate arc when the download size is unknown", async () => {
|
||||
const state = reactive({ downloading: true, progress: null as number | null });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: true, downloading: state.downloading, progress: state.progress }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const ring = container.querySelector<HTMLElement>("[data-toolbar-update-progress]");
|
||||
expect(ring).not.toBeNull();
|
||||
// Indeterminate downloading outranks the checking animation.
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(true);
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 25%, transparent 25%)");
|
||||
expect(container.querySelector("[data-toolbar-update-progress], [data-toolbar-update-scan]")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
|
||||
import { Compartment, StateEffect, StateField } from "@codemirror/state";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
@@ -2659,6 +2660,29 @@ function openNacosConsole() {
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
useUpdateBlocker(() =>
|
||||
isConfigDirty.value ||
|
||||
isCreatingConfig.value ||
|
||||
savingConfig.value ||
|
||||
deletingConfig.value ||
|
||||
pendingConfigSave.value ||
|
||||
pendingDeleteConfig.value ||
|
||||
pendingBatchDelete.value ||
|
||||
pendingHistoryRollback.value ||
|
||||
rollingBackHistory.value ||
|
||||
instanceEditorOpen.value ||
|
||||
serviceEditorOpen.value ||
|
||||
registerInstanceOpen.value ||
|
||||
pendingInstanceUpdate.value ||
|
||||
pendingInstanceDeregister.value ||
|
||||
pendingServiceDelete.value ||
|
||||
deletingService.value ||
|
||||
registeringInstance.value ||
|
||||
Object.keys(updatingInstanceKeys.value).length > 0 ||
|
||||
Object.keys(instanceWeightDrafts.value).length > 0
|
||||
? t("updates.preparationDrafts")
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
nacosDeleteConfig: mocks.nacosDeleteConfig,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, ref, shallowRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -2710,6 +2711,7 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || savingTtl.value || savingString.value || savingJson.value || savingMember.value || savingZsetMember.value || savingHashFieldTtl.value || showRenameKeyDialog.value ? t("updates.preparationDrafts") : undefined));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in new issue
Block a user