Skip to content

Commit a79b8e0

Browse files
IMB11Prospector
andauthored
feat: clean up browse shared layout logic + introduce queuing (#6030)
* feat: clean up edge case behaviour and add queued to install logic * fix: remove version choice modal * feat: queued flow * feat: standardize headers in app on proj pages * fix: clear btn * feat: installing floating popup * fix: lint * fix: onboarding/reset logic change for modpacks * qa: big ol qa * fix: lint * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
1 parent 671f6d2 commit a79b8e0

40 files changed

Lines changed: 3726 additions & 664 deletions

File tree

apps/app-frontend/src/App.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1571,11 +1571,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
15711571
15721572
.app-grid-navbar {
15731573
grid-area: nav;
1574+
position: relative;
1575+
z-index: 2;
15741576
}
15751577
15761578
.app-grid-statusbar {
15771579
grid-area: status;
15781580
padding-right: var(--window-controls-width, 0px);
1581+
position: relative;
1582+
z-index: 2;
15791583
}
15801584
15811585
[data-tauri-drag-region-exclude] {
@@ -1665,7 +1669,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
16651669
}
16661670
16671671
.app-contents::before {
1668-
z-index: 1;
1672+
z-index: 30;
16691673
content: '';
16701674
position: fixed;
16711675
left: var(--left-bar-width);
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import type { Labrinth } from '@modrinth/api-client'
2+
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
3+
import type { CardAction } from '@modrinth/ui'
4+
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
5+
import { openUrl } from '@tauri-apps/plugin-opener'
6+
import type { ComputedRef, Ref } from 'vue'
7+
import { onUnmounted, ref, shallowRef } from 'vue'
8+
import type { Router } from 'vue-router'
9+
10+
import { process_listener } from '@/helpers/events'
11+
import { get_by_profile_path } from '@/helpers/process'
12+
import { kill, list as listInstances } from '@/helpers/profile.js'
13+
import type { GameInstance } from '@/helpers/types'
14+
import { add_server_to_profile, getServerLatency } from '@/helpers/worlds'
15+
import { getServerAddress } from '@/store/install.js'
16+
17+
interface BrowseServerInstance {
18+
name: string
19+
path: string
20+
}
21+
22+
interface ContextMenuHandle {
23+
showMenu: (
24+
event: MouseEvent,
25+
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
26+
options: { name: string }[],
27+
) => void
28+
}
29+
30+
interface ContextMenuOptionClick {
31+
option: 'open_link' | 'copy_link'
32+
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
33+
}
34+
35+
export interface UseAppServerBrowseOptions {
36+
instance: Ref<BrowseServerInstance | null>
37+
isFromWorlds: ComputedRef<boolean>
38+
allInstalledIds: ComputedRef<Set<string>>
39+
newlyInstalled: Ref<string[]>
40+
installingServerProjects: Ref<string[]>
41+
playServerProject: (projectId: string) => Promise<void>
42+
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
43+
handleError: (error: unknown) => void
44+
router: Router
45+
}
46+
47+
const messages = defineMessages({
48+
addToInstance: {
49+
id: 'app.browse.add-to-instance',
50+
defaultMessage: 'Add to instance',
51+
},
52+
addToInstanceName: {
53+
id: 'app.browse.add-to-instance-name',
54+
defaultMessage: 'Add to {instanceName}',
55+
},
56+
added: {
57+
id: 'app.browse.added',
58+
defaultMessage: 'Added',
59+
},
60+
alreadyAdded: {
61+
id: 'app.browse.already-added',
62+
defaultMessage: 'Already added',
63+
},
64+
})
65+
66+
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
67+
const { formatMessage } = useVIntl()
68+
const debugLog = useDebugLogger('BrowseServer')
69+
const serverPings = shallowRef<Record<string, number | undefined>>({})
70+
const serverPingCache = new Map<string, number | undefined>()
71+
const pendingServerPings = new Map<string, Promise<number | undefined>>()
72+
const runningServerProjects = ref<Record<string, string>>({})
73+
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
74+
const contextMenuRef = ref<ContextMenuHandle | null>(null)
75+
let serverPingCacheActive = true
76+
let unlistenProcesses: (() => void) | null = null
77+
78+
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
79+
debugLog('checkServerRunningStates', { hitCount: hits.length })
80+
const packs = await listInstances().catch((error) => {
81+
options.handleError(error)
82+
return []
83+
})
84+
const newRunning: Record<string, string> = {}
85+
for (const hit of hits) {
86+
const inst = packs.find(
87+
(pack: GameInstance) => pack.linked_data?.project_id === hit.project_id,
88+
)
89+
if (inst) {
90+
const processes = await get_by_profile_path(inst.path).catch(() => [])
91+
if (Array.isArray(processes) && processes.length > 0) {
92+
newRunning[hit.project_id] = inst.path
93+
}
94+
}
95+
}
96+
debugLog('runningServerProjects updated', newRunning)
97+
runningServerProjects.value = newRunning
98+
}
99+
100+
async function handleStopServerProject(projectId: string) {
101+
debugLog('handleStopServerProject', projectId)
102+
const instancePath = runningServerProjects.value[projectId]
103+
if (!instancePath) return
104+
await kill(instancePath).catch(() => {})
105+
const { [projectId]: _, ...rest } = runningServerProjects.value
106+
runningServerProjects.value = rest
107+
}
108+
109+
async function handlePlayServerProject(projectId: string) {
110+
debugLog('handlePlayServerProject', projectId)
111+
await options.playServerProject(projectId)
112+
checkServerRunningStates(lastServerHits.value)
113+
}
114+
115+
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
116+
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
117+
const address = getServerAddress(project.minecraft_java_server)
118+
if (!address) return
119+
120+
if (options.instance.value) {
121+
try {
122+
await add_server_to_profile(
123+
options.instance.value.path,
124+
project.name,
125+
address,
126+
'prompt',
127+
project.project_id,
128+
project.minecraft_java_server?.content?.kind,
129+
)
130+
options.newlyInstalled.value.push(project.project_id)
131+
} catch (error) {
132+
options.handleError(error)
133+
}
134+
} else {
135+
options.showAddServerToInstanceModal(project.name, address)
136+
}
137+
}
138+
139+
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
140+
debugLog('pingServerHits', { hitCount: hits.length })
141+
const pingsToFetch = hits.flatMap((hit) => {
142+
const address = hit.minecraft_java_server?.address
143+
if (!address) return []
144+
return [{ hit, address }]
145+
})
146+
const nextPings = { ...serverPings.value }
147+
for (const { hit, address } of pingsToFetch) {
148+
if (serverPingCache.has(address)) {
149+
nextPings[hit.project_id] = serverPingCache.get(address)
150+
}
151+
}
152+
serverPings.value = nextPings
153+
154+
await Promise.all(
155+
pingsToFetch.map(async ({ hit, address }) => {
156+
if (serverPingCache.has(address)) return
157+
158+
let pending = pendingServerPings.get(address)
159+
if (!pending) {
160+
pending = getServerLatency(address)
161+
.then((latency) => {
162+
if (serverPingCacheActive) serverPingCache.set(address, latency)
163+
return latency
164+
})
165+
.catch((error) => {
166+
console.error(`Failed to ping server ${address}:`, error)
167+
if (serverPingCacheActive) serverPingCache.set(address, undefined)
168+
return undefined
169+
})
170+
.finally(() => {
171+
pendingServerPings.delete(address)
172+
})
173+
pendingServerPings.set(address, pending)
174+
}
175+
176+
const latency = await pending
177+
if (!serverPingCacheActive) return
178+
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
179+
}),
180+
)
181+
}
182+
183+
function updateServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
184+
lastServerHits.value = hits
185+
pingServerHits(hits)
186+
checkServerRunningStates(hits)
187+
}
188+
189+
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
190+
const content = project.minecraft_java_server?.content
191+
if (content?.kind === 'modpack') {
192+
const { project_name, project_icon, project_id } = content
193+
if (!project_name) return undefined
194+
return {
195+
name: project_name,
196+
icon: project_icon ?? undefined,
197+
onclick:
198+
project_id !== project.project_id
199+
? () => {
200+
options.router.push(`/project/${project_id}`)
201+
}
202+
: undefined,
203+
showCustomModpackTooltip: project_id === project.project_id,
204+
}
205+
}
206+
return undefined
207+
}
208+
209+
function getServerCardActions(
210+
serverResult: Labrinth.Search.v3.ResultSearchProject,
211+
): CardAction[] {
212+
const isInstalled = options.allInstalledIds.value.has(serverResult.project_id)
213+
214+
if (options.isFromWorlds.value && options.instance.value) {
215+
return [
216+
{
217+
key: 'add-to-instance',
218+
label: formatMessage(isInstalled ? messages.added : messages.addToInstance),
219+
icon: isInstalled ? CheckIcon : PlusIcon,
220+
disabled: isInstalled,
221+
color: 'brand',
222+
type: 'outlined',
223+
onClick: () => handleAddServerToInstance(serverResult),
224+
},
225+
]
226+
}
227+
228+
const actions: CardAction[] = []
229+
230+
actions.push({
231+
key: 'add',
232+
label: '',
233+
icon: isInstalled ? CheckIcon : PlusIcon,
234+
disabled: isInstalled,
235+
circular: true,
236+
tooltip: isInstalled
237+
? formatMessage(messages.alreadyAdded)
238+
: options.instance.value
239+
? formatMessage(messages.addToInstanceName, {
240+
instanceName: options.instance.value.name,
241+
})
242+
: formatMessage(commonMessages.addServerToInstanceButton),
243+
onClick: () => handleAddServerToInstance(serverResult),
244+
})
245+
246+
if (runningServerProjects.value[serverResult.project_id]) {
247+
actions.push({
248+
key: 'stop',
249+
label: formatMessage(commonMessages.stopButton),
250+
icon: StopCircleIcon,
251+
color: 'red',
252+
type: 'outlined',
253+
onClick: () => handleStopServerProject(serverResult.project_id),
254+
})
255+
} else {
256+
const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id)
257+
actions.push({
258+
key: 'play',
259+
label: formatMessage(
260+
isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
261+
),
262+
icon: PlayIcon,
263+
disabled: isInstalling,
264+
color: 'brand',
265+
type: 'outlined',
266+
onClick: () => handlePlayServerProject(serverResult.project_id),
267+
})
268+
}
269+
270+
return actions
271+
}
272+
273+
function handleRightClick(
274+
event: MouseEvent,
275+
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
276+
) {
277+
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
278+
}
279+
280+
function handleOptionsClick(args: ContextMenuOptionClick) {
281+
const url = getProjectUrl(args.item)
282+
switch (args.option) {
283+
case 'open_link':
284+
openUrl(url)
285+
break
286+
case 'copy_link':
287+
navigator.clipboard.writeText(url)
288+
break
289+
}
290+
}
291+
292+
process_listener((event: { event: string; profile_path_id: string }) => {
293+
debugLog('process event', event)
294+
if (event.event === 'finished') {
295+
const projectId = Object.entries(runningServerProjects.value).find(
296+
([, path]) => path === event.profile_path_id,
297+
)?.[0]
298+
if (projectId) {
299+
const { [projectId]: _, ...rest } = runningServerProjects.value
300+
runningServerProjects.value = rest
301+
}
302+
}
303+
})
304+
.then((unlisten) => {
305+
unlistenProcesses = unlisten
306+
})
307+
.catch(options.handleError)
308+
309+
onUnmounted(() => {
310+
serverPingCacheActive = false
311+
unlistenProcesses?.()
312+
serverPingCache.clear()
313+
pendingServerPings.clear()
314+
})
315+
316+
return {
317+
serverPings,
318+
contextMenuRef,
319+
updateServerHits,
320+
getServerModpackContent,
321+
getServerCardActions,
322+
handleRightClick,
323+
handleOptionsClick,
324+
}
325+
}
326+
327+
function getProjectUrl(
328+
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
329+
) {
330+
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
331+
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
332+
}

0 commit comments

Comments
 (0)