Files
Modrinth-plus/apps/frontend/src/pages/hosting/manage/[id]/index.vue
Calum H. 7d92e4ec7f feat: content tab rewrite for worlds (#5136)
* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
2026-03-12 13:24:32 -07:00

729 lines
17 KiB
Vue

<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Your server is still accessible during this time.
</Admonition>
<Admonition
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
type="critical"
:header="`${serverData?.name} shut down unexpectedly.`"
dismissible
@dismiss="clearError"
>
<template v-if="inspectingError.analysis.problems.length">
<p class="m-0 text-sm opacity-80">
We automatically analyzed the logs and found the following:
</p>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="bg-raised-bg/30 rounded-xl px-3 py-2"
>
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
<li
v-for="solution in problem.solutions"
:key="solution.message"
class="text-sm opacity-80"
>
{{ solution.message }}
</li>
</ul>
</div>
</div>
</template>
<template v-else-if="props.serverPowerState === 'crashed'">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused by a
mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
Your server exited with code {{ props.powerStateDetails.exit_code }}.
<template v-if="props.powerStateDetails.exit_code === 1">
There may be a mod or plugin causing the issue, or an issue with your server
configuration.
</template>
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<p class="m-0 mt-2">You can try restarting the server.</p>
</template>
<template v-else>
We could not find any specific problems, but you can try restarting the server.
</template>
</Admonition>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
:loading="!isConnected || isWsAuthIncorrect"
/>
<div
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
:class="{ 'border-0': !isConnected || isWsAuthIncorrect }"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<PanelServerStatus v-if="isConnected && !isWsAuthIncorrect" :state="serverPowerState" />
</div>
</div>
<PanelTerminal :full-screen="fullScreen" :loading="!isConnected || isWsAuthIncorrect">
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
id="command-suggestions"
ref="suggestionsList"
class="mt-1 max-h-60 w-full list-none overflow-auto rounded-md border border-divider bg-bg-raised p-0 shadow-lg"
role="listbox"
>
<li
v-for="(suggestion, index) in suggestions"
:id="'suggestion-' + index"
:key="index"
role="option"
:aria-selected="index === selectedSuggestionIndex"
:class="[
'cursor-pointer px-4 py-2',
index === selectedSuggestionIndex ? 'bg-bg-raised' : 'bg-bg',
]"
@click="selectSuggestion(index)"
@mousemove="() => (selectedSuggestionIndex = index)"
>
{{ suggestion }}
</li>
</ul>
<div class="relative flex items-center">
<span
v-if="bestSuggestion && isConnected && !isWsAuthIncorrect"
class="pointer-events-none absolute left-[26px] transform select-none text-gray-400"
>
<span class="ml-[23.5px] whitespace-pre">{{
' '.repeat(commandInput.length - 1)
}}</span>
<span> {{ bestSuggestion }} </span>
<button
class="text pointer-events-auto ml-2 cursor-pointer rounded-md border-none bg-white text-sm focus:outline-none dark:bg-highlight"
aria-label="Accept suggestion"
style="transform: translateY(-1px)"
@click="acceptSuggestion"
>
TAB
</button>
</span>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full items-center"
>
<TerminalSquareIcon class="ml-3 h-5 w-5" />
</div>
<input
v-if="isServerRunning && isConnected && !isWsAuthIncorrect"
v-model="commandInput"
type="text"
placeholder="Send a command"
class="w-full rounded-md !pl-10 pt-4 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
aria-autocomplete="list"
aria-controls="command-suggestions"
spellcheck="false"
:aria-activedescendant="'suggestion-' + selectedSuggestionIndex"
@keydown.tab.prevent="acceptSuggestion"
@keydown.down.prevent="selectNextSuggestion"
@keydown.up.prevent="selectPrevSuggestion"
@keydown.enter.prevent="sendCommand"
/>
<input
v-else
disabled
type="text"
placeholder="Send a command"
class="w-full rounded-md !pl-10 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
/>
</div>
</div>
</PanelTerminal>
</div>
</div>
<div
v-if="isWsAuthIncorrect"
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
>
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the
page. (WebSocket Authentication Failed)
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { TerminalSquareIcon } from '@modrinth/assets'
import {
Admonition,
injectModrinthClient,
injectModrinthServerContext,
useVIntl,
} from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
type ServerProps = {
isConnected: boolean
isWsAuthIncorrect: boolean
stats: Stats
serverPowerState: ServerState
powerStateDetails?: {
oom_killed?: boolean
exit_code?: number
}
isServerRunning: boolean
}
const props = defineProps<ServerProps>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
interface ErrorData {
id: string
name: string
type: string
version: string
title: string
analysis: {
problems: Array<{
message: string
counter: number
entry: {
level: number
time: string | null
prefix: string
lines: Array<{ number: number; content: string }>
}
solutions: Array<{ message: string }>
}>
information: Array<{
message: string
counter: number
label: string
value: string
entry: {
level: number
time: string | null
prefix: string
lines: Array<{ number: number; content: string }>
}
}>
}
}
const inspectingError = ref<ErrorData | null>(null)
const inspectError = async () => {
try {
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
const log = await blob.text()
if (!log) return
// @ts-ignore
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
content: log,
}),
})
// @ts-ignore
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
inspectingError.value = response as ErrorData
} else {
inspectingError.value = null
}
} catch (error) {
console.error('Failed to analyze logs:', error)
inspectingError.value = null
}
}
const clearError = () => {
inspectingError.value = null
}
watch(
() => props.serverPowerState,
(newVal) => {
if (newVal === 'crashed' && !props.powerStateDetails?.oom_killed) {
inspectError()
} else {
clearError()
}
},
)
if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed) {
inspectError()
}
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
const commandTree: any = {
advancement: {
grant: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
revoke: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
},
ban: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
duration: {
[DYNAMIC_ARG]: null,
},
},
},
'ban-ip': null,
banlist: {
ips: null,
players: null,
all: null,
},
bossbar: {
add: null,
get: null,
list: null,
remove: null,
set: null,
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
reason: null,
},
},
},
clone: null,
data: {
get: null,
merge: null,
modify: null,
remove: null,
},
datapack: {
disable: null,
enable: null,
list: null,
reload: null,
},
debug: {
start: null,
stop: null,
function: null,
memory: null,
},
defaultgamemode: {
survival: null,
creative: null,
adventure: null,
spectator: null,
},
deop: null,
difficulty: {
peaceful: null,
easy: null,
normal: null,
hard: null,
},
effect: {
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
true: null,
false: null,
},
},
},
},
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
enchant: null,
execute: null,
experience: {
add: null,
set: null,
query: null,
},
fill: null,
forceload: {
add: null,
remove: null,
query: null,
},
function: null,
gamemode: {
survival: {
[DYNAMIC_ARG]: null,
},
creative: {
[DYNAMIC_ARG]: null,
},
adventure: {
[DYNAMIC_ARG]: null,
},
spectator: {
[DYNAMIC_ARG]: null,
},
},
gamerule: null,
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
help: null,
kick: null,
kill: {
[DYNAMIC_ARG]: null,
},
list: null,
locate: {
biome: null,
poi: null,
structure: null,
},
loot: {
give: null,
insert: null,
replace: null,
spawn: null,
},
me: null,
msg: null,
op: null,
pardon: null,
'pardon-ip': null,
particle: null,
playsound: null,
recipe: {
give: null,
take: null,
},
reload: null,
say: null,
schedule: {
function: null,
clear: null,
},
scoreboard: {
objectives: {
add: null,
remove: null,
setdisplay: null,
list: null,
modify: null,
},
players: {
add: null,
remove: null,
set: null,
get: null,
list: null,
enable: null,
operation: null,
reset: null,
},
},
seed: null,
setblock: null,
setidletimeout: null,
setworldspawn: null,
spawnpoint: null,
spectate: null,
spreadplayers: null,
stop: null,
stopsound: null,
summon: null,
tag: {
add: null,
list: null,
remove: null,
},
team: {
add: null,
empty: null,
join: null,
leave: null,
list: null,
modify: null,
remove: null,
},
teleport: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
},
tp: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
trigger: null,
weather: {
clear: {
[DYNAMIC_ARG]: null,
},
rain: {
[DYNAMIC_ARG]: null,
},
thunder: {
[DYNAMIC_ARG]: null,
},
},
whitelist: {
add: null,
list: null,
off: null,
on: null,
reload: null,
remove: null,
},
worldborder: {
add: null,
center: null,
damage: {
amount: null,
buffer: null,
},
get: null,
set: null,
warning: {
distance: null,
time: null,
},
},
xp: null,
}
const fullScreen = ref(false)
const commandInput = ref('')
const suggestions = ref<string[]>([])
const selectedSuggestionIndex = ref(0)
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");
const suggestionsList = ref<HTMLUListElement | null>(null)
useHead({
title: `Overview - ${serverData.value?.name ?? 'Server'} - Modrinth`,
})
const bestSuggestion = computed(() => {
if (!suggestions.value.length) return ''
const inputTokens = commandInput.value.trim().split(/\s+/)
let lastInputToken = inputTokens[inputTokens.length - 1] || ''
if (inputTokens.length - 1 === 0 && lastInputToken.startsWith('/')) {
lastInputToken = lastInputToken.slice(1)
}
const selectedSuggestion = suggestions.value[selectedSuggestionIndex.value]
const suggestionTokens = selectedSuggestion.split(/\s+/)
const lastSuggestionToken = suggestionTokens[suggestionTokens.length - 1] || ''
if (lastSuggestionToken.toLowerCase().startsWith(lastInputToken.toLowerCase())) {
return lastSuggestionToken.slice(lastInputToken.length)
}
return ''
})
const getSuggestions = (input: string): string[] => {
const trimmedInput = input.trim()
const inputWithoutSlash = trimmedInput.startsWith('/') ? trimmedInput.slice(1) : trimmedInput
const tokens = inputWithoutSlash.split(/\s+/)
let currentLevel: any = commandTree
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i].toLowerCase()
if (currentLevel?.[token]) {
currentLevel = currentLevel[token] as any
} else if (currentLevel?.[DYNAMIC_ARG]) {
currentLevel = currentLevel[DYNAMIC_ARG] as any
} else {
if (i === tokens.length - 1) {
break
}
currentLevel = null
break
}
}
if (currentLevel) {
const lastToken = tokens[tokens.length - 1]?.toLowerCase() || ''
const possibleKeys = Object.keys(currentLevel)
if (currentLevel[DYNAMIC_ARG]) {
possibleKeys.push('<arg>')
}
return possibleKeys
.filter((key) => key === '<arg>' || key.toLowerCase().startsWith(lastToken))
.filter((k) => k !== lastToken.trim())
.map((key) => {
if (key === '<arg>') {
return [...tokens.slice(0, -1), '<arg>'].join(' ')
}
const newTokens = [...tokens.slice(0, -1), key]
return newTokens.join(' ')
})
}
return []
}
const sendCommand = () => {
const cmd = commandInput.value.trim()
if (!props.isConnected || !cmd) return
try {
sendConsoleCommand(cmd)
commandInput.value = ''
suggestions.value = []
selectedSuggestionIndex.value = 0
} catch (error) {
console.error('Error sending command:', error)
}
}
const sendConsoleCommand = (cmd: string) => {
try {
client.archon.sockets.send(serverId, { event: 'command', cmd })
} catch (error) {
console.error('Error sending command:', error)
}
}
watch(
() => selectedSuggestionIndex.value,
(newVal) => {
if (suggestionsList.value) {
const selectedSuggestion = suggestionsList.value.querySelector(`#suggestion-${newVal}`)
if (selectedSuggestion) {
selectedSuggestion.scrollIntoView({ block: 'nearest' })
}
}
},
)
watch(
() => commandInput.value,
(newVal) => {
const trimmed = newVal.trim()
if (!trimmed) {
suggestions.value = []
return
}
suggestions.value = getSuggestions(newVal)
selectedSuggestionIndex.value = 0
},
)
const selectNextSuggestion = () => {
if (suggestions.value.length === 0) return
selectedSuggestionIndex.value = (selectedSuggestionIndex.value + 1) % suggestions.value.length
}
const selectPrevSuggestion = () => {
if (suggestions.value.length === 0) return
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value - 1 + suggestions.value.length) % suggestions.value.length
}
const acceptSuggestion = () => {
if (suggestions.value.filter((s) => s !== '<arg>').length === 0) return
const selected = suggestions.value[selectedSuggestionIndex.value]
const currentTokens = commandInput.value.trim().split(' ')
const suggestionTokens = selected.split(/\s+/).filter(Boolean)
// check if last current token is in command tree if so just add to the end
if (currentTokens[currentTokens.length - 1].toLowerCase() === suggestionTokens[0].toLowerCase()) {
/* empty */
} else {
const offset = currentTokens.length - 1 === 0 && currentTokens[0].trim().startsWith('/') ? 1 : 0
commandInput.value =
commandInput.value +
suggestionTokens[suggestionTokens.length - 1].substring(
currentTokens[currentTokens.length - 1].length - offset,
) +
' '
suggestions.value = getSuggestions(commandInput.value)
selectedSuggestionIndex.value = 0
}
}
const selectSuggestion = (index: number) => {
selectedSuggestionIndex.value = index
acceptSuggestion()
}
</script>