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
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'

Expand All @@ -11,9 +19,23 @@ import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'

const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'

const messages = defineMessages({
alwaysShowCopyDetailsTitle: {
id: 'app.resource-management-settings.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.resource-management-settings.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
})

watch(
settings,
Expand Down Expand Up @@ -154,6 +176,28 @@ async function findLauncherDir() {
</p>
</div>

<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>

<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
Expand Down
138 changes: 16 additions & 122 deletions apps/app-frontend/src/composables/browse/install-job-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { useTheming } from '@/store/state'

const messages = defineMessages({
installs: {
Expand Down Expand Up @@ -221,13 +222,6 @@ const failureSummaryMessages = defineMessages({
})

const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000

interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}

function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
Expand All @@ -241,6 +235,7 @@ export async function useInstallJobNotifications(opts: {
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
Expand All @@ -250,7 +245,6 @@ export async function useInstallJobNotifications(opts: {
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()

function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
Expand Down Expand Up @@ -421,116 +415,14 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}

function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}

function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}

function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}

function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}

const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}

const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}

function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}

function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}

snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}

snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}

function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()

for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}

trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}

clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}

for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}

function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}

function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
}

function isCopied(job: InstallJobSnapshot): boolean {
Expand Down Expand Up @@ -607,6 +499,16 @@ export async function useInstallJobNotifications(opts: {
return buttons
}

function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}

function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
Expand All @@ -615,7 +517,6 @@ export async function useInstallJobNotifications(opts: {
}

const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)

jobs.value = visibleJobs.sort(
(a, b) =>
Expand All @@ -642,12 +543,8 @@ export async function useInstallJobNotifications(opts: {
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
}
}),
)
Expand Down Expand Up @@ -730,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}

await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
await refresh(false)

return {
active: computed(() => jobs.value.length > 0),
Expand All @@ -743,9 +640,6 @@ export async function useInstallJobNotifications(opts: {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
Expand Down
6 changes: 6 additions & 0 deletions apps/app-frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,12 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.resource-management-settings.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
},
"app.resource-management-settings.always-show-copy-details.title": {
"message": "Always show copy details"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
Expand Down
1 change: 1 addition & 0 deletions apps/app-frontend/src/store/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS = {
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
always_show_copy_details: false,
}

export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
Expand Down
19 changes: 14 additions & 5 deletions packages/app-lib/src/install/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,20 @@ pub async fn emit_install_job(
{
use tauri::Emitter;

let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
let result: crate::Result<()> = (|| {
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(
"Failed to emit install job {} update: {error}",
snapshot.job_id
);
}
}

Ok(())
Expand Down
Loading
Loading