1098 lines
34 KiB
Vue
1098 lines
34 KiB
Vue
<template>
|
|
<main class="flex min-h-0 w-full min-w-0 max-w-full flex-1 flex-col gap-6">
|
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div class="flex min-w-0 items-start gap-3">
|
|
<span
|
|
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
|
>
|
|
<UIcon :name="pageShell.icon" class="size-5" />
|
|
</span>
|
|
|
|
<div class="min-w-0 space-y-2">
|
|
<div
|
|
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
|
>
|
|
<span>{{ pageShell.sectionLabel }}</span>
|
|
<span>/</span>
|
|
<span>{{ pageShell.pageLabel }}</span>
|
|
</div>
|
|
|
|
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap justify-end gap-2 xl:justify-end">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-circle-help"
|
|
:disabled="isStartBlocked"
|
|
@click="() => void runHelp()"
|
|
>
|
|
Help
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-eraser"
|
|
@click="() => void clearOutput()"
|
|
>
|
|
Clear output
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<UPageCard variant="naked" :ui="pageCardUi" class="flex min-h-0 flex-1">
|
|
<template #body>
|
|
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
|
<div
|
|
class="flex min-h-72 min-w-0 flex-1 overflow-hidden rounded-sm border border-default bg-neutral-950/95 shadow-sm"
|
|
>
|
|
<div
|
|
ref="terminal_window"
|
|
class="terminal-host h-full min-h-0 w-full overflow-hidden"
|
|
/>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-default bg-default shadow-sm">
|
|
<div
|
|
class="flex flex-col gap-3 border-b border-default bg-muted/10 px-4 py-3 lg:flex-row lg:items-start lg:justify-between"
|
|
>
|
|
<div class="min-w-0 flex-1 space-y-1">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div
|
|
class="flex min-w-0 flex-wrap items-center gap-2 text-sm font-semibold text-highlighted"
|
|
>
|
|
<UIcon name="i-lucide-send" class="size-4 shrink-0 text-toned" />
|
|
<span>Command</span>
|
|
|
|
<UBadge :color="sessionStatusColor" variant="soft" size="sm">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<UIcon
|
|
:name="sessionStatusIcon"
|
|
class="size-3.5"
|
|
:class="sessionStatusSpinning ? 'animate-spin' : ''"
|
|
/>
|
|
<span>{{ sessionStatusLabel }}</span>
|
|
</span>
|
|
</UBadge>
|
|
</div>
|
|
|
|
<UBadge :color="isMultiLineInput ? 'info' : 'neutral'" variant="soft" size="sm">
|
|
{{ isMultiLineInput ? 'Multi-line' : 'Single-line' }}
|
|
</UBadge>
|
|
</div>
|
|
|
|
<p class="text-xs text-toned">
|
|
<kbd><kbd>Shift</kbd>+<kbd>Enter</kbd></kbd> to switch to multi-line input.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3 px-4 py-4">
|
|
<UAlert
|
|
v-if="sessionError"
|
|
color="error"
|
|
variant="soft"
|
|
icon="i-lucide-triangle-alert"
|
|
:title="sessionErrorTitle"
|
|
:description="sessionError"
|
|
/>
|
|
|
|
<UAlert
|
|
v-if="sessionStatus === 'reconnecting'"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-rotate-cw"
|
|
title="Reconnecting to the command stream"
|
|
description="The connection was lost. Attempting to reconnect and restore the stream."
|
|
/>
|
|
|
|
<UAlert
|
|
v-if="sessionStatus === 'interrupted'"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-circle-off"
|
|
title="Session interrupted"
|
|
description="The command execution was interrupted."
|
|
/>
|
|
|
|
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
|
<div class="space-y-3">
|
|
<TextareaAutocomplete
|
|
v-if="isMultiLineInput"
|
|
ref="commandTextarea"
|
|
v-model="command"
|
|
class="console-input"
|
|
:options="ytDlpOptions"
|
|
:disabled="isStartBlocked"
|
|
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
|
|
:icon-class="isLoading ? 'animate-spin' : ''"
|
|
placeholder="--help"
|
|
:rows="5"
|
|
@keydown="handleKeyDown"
|
|
/>
|
|
|
|
<InputAutocomplete
|
|
v-else
|
|
ref="commandInput"
|
|
v-model="command"
|
|
class="console-input"
|
|
:options="ytDlpOptions"
|
|
:disabled="isStartBlocked"
|
|
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
|
|
:icon-class="isLoading ? 'animate-spin' : ''"
|
|
placeholder="--help"
|
|
:multiple="true"
|
|
:allowShortFlags="true"
|
|
@keydown="handleKeyDown"
|
|
@paste="handlePaste"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-end gap-2 xl:self-end">
|
|
<UPopover :content="{ side: 'top', align: 'end', sideOffset: 8 }">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="lg"
|
|
icon="i-lucide-history"
|
|
trailing-icon="i-lucide-chevron-up"
|
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
|
>
|
|
History
|
|
</UButton>
|
|
|
|
<template #content>
|
|
<UCard
|
|
class="w-[min(92vw,42rem)] border border-default/70 shadow-sm"
|
|
:ui="historyCardUi"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div
|
|
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
|
>
|
|
<UIcon name="i-lucide-history" class="size-4 text-toned" />
|
|
<span>Recent runs</span>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-eye-off"
|
|
:disabled="recentSessionEntries.length < 1"
|
|
@click="() => void hideRecentSessions()"
|
|
>
|
|
Hide recent runs
|
|
</UButton>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="recentSessionEntries.length < 1"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-clock-3"
|
|
title="No recent console sessions"
|
|
/>
|
|
|
|
<div
|
|
v-else
|
|
class="max-h-96 w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
|
>
|
|
<div class="w-full max-w-full overflow-auto overscroll-contain">
|
|
<table class="w-full text-sm">
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="item in recentSessionEntries"
|
|
:key="item.sessionId"
|
|
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
|
>
|
|
<td class="px-3 py-3 align-middle">
|
|
<div class="space-y-2">
|
|
<button
|
|
type="button"
|
|
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
|
|
@click="() => void replayRecentSession(item)"
|
|
>
|
|
{{ item.command }}
|
|
</button>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center gap-2 text-[11px] text-toned"
|
|
>
|
|
<UBadge
|
|
:color="recentSessionStatusColor(item)"
|
|
variant="soft"
|
|
size="sm"
|
|
>
|
|
<span
|
|
v-if="recentSessionStatusSpinning(item)"
|
|
class="inline-flex items-center gap-1.5"
|
|
>
|
|
<UIcon
|
|
:name="recentSessionStatusIcon(item)"
|
|
class="size-3.5 animate-spin"
|
|
/>
|
|
<span>{{ recentSessionStatusLabel(item) }}</span>
|
|
</span>
|
|
<span v-else class="inline-flex items-center gap-1.5">
|
|
<UIcon
|
|
:name="recentSessionStatusIcon(item)"
|
|
class="size-3.5"
|
|
/>
|
|
<span>{{ recentSessionStatusLabel(item) }}</span>
|
|
</span>
|
|
</UBadge>
|
|
|
|
<UTooltip
|
|
v-if="item.finishedAt || item.startedAt || item.createdAt"
|
|
:text="
|
|
moment
|
|
.unix(
|
|
item.finishedAt ||
|
|
item.startedAt ||
|
|
item.createdAt ||
|
|
0,
|
|
)
|
|
.format('YYYY-M-DD H:mm Z')
|
|
"
|
|
>
|
|
<span
|
|
class="inline-flex items-center gap-1.5 cursor-help"
|
|
>
|
|
<UIcon name="i-lucide-clock-3" class="size-3.5" />
|
|
<time
|
|
:datetime="
|
|
moment
|
|
.unix(
|
|
item.finishedAt ||
|
|
item.startedAt ||
|
|
item.createdAt ||
|
|
0,
|
|
)
|
|
.toISOString()
|
|
"
|
|
>
|
|
{{
|
|
moment
|
|
.unix(
|
|
item.finishedAt ||
|
|
item.startedAt ||
|
|
item.createdAt ||
|
|
0,
|
|
)
|
|
.fromNow()
|
|
}}
|
|
</time>
|
|
</span>
|
|
</UTooltip>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td
|
|
class="w-28 px-3 py-3 text-center align-middle whitespace-nowrap"
|
|
>
|
|
<div class="flex items-center justify-end gap-1">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-terminal"
|
|
@click="() => void loadCommand(item.command)"
|
|
>
|
|
Fill
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-x"
|
|
square
|
|
@click="hideRecentSession(item.sessionId)"
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UPopover>
|
|
|
|
<UButton
|
|
v-if="showCancelButton"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="lg"
|
|
icon="i-lucide-power"
|
|
:loading="cancelPending"
|
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
|
@click="() => void cancelCommand()"
|
|
>
|
|
Close output
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-else-if="canManualReconnect"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="lg"
|
|
icon="i-lucide-rotate-cw"
|
|
:loading="manualReconnectPending"
|
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
|
@click="() => void reconnectSession()"
|
|
>
|
|
Reconnect
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-else
|
|
color="primary"
|
|
size="lg"
|
|
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-send'"
|
|
:loading="isLoading"
|
|
:disabled="!canStartCommand"
|
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
|
@click="() => void runCommand()"
|
|
>
|
|
{{ runButtonLabel }}
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UPageCard>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.console-input :deep(input),
|
|
.console-input :deep(textarea) {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
.terminal-host :deep(.xterm) {
|
|
height: 100%;
|
|
padding: 0.75rem !important;
|
|
}
|
|
|
|
.terminal-host :deep(.xterm-viewport) {
|
|
-ms-overflow-style: none;
|
|
background-color: transparent !important;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.terminal-host :deep(.xterm-viewport::-webkit-scrollbar) {
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
</style>
|
|
|
|
<script setup lang="ts">
|
|
import moment from 'moment';
|
|
import { useStorage } from '@vueuse/core';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
|
import { useConsoleSession } from '~/composables/useConsoleSession';
|
|
import { useDialog } from '~/composables/useDialog';
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
import { disableOpacity, enableOpacity } from '~/utils';
|
|
import { requirePageShell } from '~/utils/topLevelNavigation';
|
|
|
|
const ACTIVE_SESSION_STATUSES = ['starting', 'running', 'reconnecting'];
|
|
|
|
let flushFrame: number | null = null;
|
|
let fitFrame: number | null = null;
|
|
let terminalResizeObserver: ResizeObserver | null = null;
|
|
let didInitialRender = false;
|
|
let renderedChunkCount = 0;
|
|
|
|
const config = useYtpConfig();
|
|
const toast = useNotification();
|
|
const dialog = useDialog();
|
|
const consoleSession = useConsoleSession();
|
|
const pageShell = requirePageShell('console');
|
|
|
|
const terminal = shallowRef<Terminal | null>(null);
|
|
const terminalFit = shallowRef<FitAddon | null>(null);
|
|
const command = ref('');
|
|
const manualReconnectPending = ref(false);
|
|
const cancelPending = ref(false);
|
|
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
|
|
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
|
|
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
|
const storedCommand = useStorage<string>('console_command', '');
|
|
|
|
const pageCardUi = {
|
|
root: 'flex min-h-0 flex-1 w-full bg-transparent',
|
|
container: 'flex min-h-0 flex-1 w-full p-0 sm:p-0',
|
|
wrapper: 'flex min-h-0 flex-1 w-full items-stretch',
|
|
body: 'flex min-h-0 flex-1 w-full flex-col',
|
|
};
|
|
|
|
const historyCardUi = {
|
|
body: 'space-y-3 p-4',
|
|
};
|
|
|
|
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
|
config.ytdlp_options.flatMap((opt) =>
|
|
opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
|
|
),
|
|
);
|
|
|
|
const bufferedTranscript = consoleSession.bufferedTranscript;
|
|
const recentSessions = consoleSession.recentSessions;
|
|
const sessionStatus = computed(() => consoleSession.state.value.status);
|
|
const sessionError = computed(() => consoleSession.state.value.error);
|
|
const sessionExitCode = computed(() => consoleSession.state.value.exitCode);
|
|
const hasActiveSession = computed(() => Boolean(consoleSession.state.value.sessionId));
|
|
const displayCommand = computed(() => command.value.trim().replace(/^yt-dlp\b\s*/i, ''));
|
|
const runnableCommand = computed(() => displayCommand.value.replace(/\n/g, ' ').trim());
|
|
const hasValidCommand = computed(() => Boolean(runnableCommand.value));
|
|
const isLoading = computed(() => consoleSession.isLoading.value);
|
|
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
|
|
const recentSessionEntries = computed(() => recentSessions.value);
|
|
const canManualReconnect = computed(() => sessionStatus.value === 'reconnecting');
|
|
const showCancelButton = computed(() =>
|
|
['starting', 'running', 'reconnecting'].includes(sessionStatus.value),
|
|
);
|
|
const isStartBlocked = computed(() => isLoading.value);
|
|
const canStartCommand = computed(() => !isStartBlocked.value && hasValidCommand.value);
|
|
const runButtonLabel = computed(() => {
|
|
if (runnableCommand.value === 'clear') {
|
|
return 'Clear output';
|
|
}
|
|
|
|
if (
|
|
consoleSession.state.value.command.trim() &&
|
|
consoleSession.state.value.command.trim() === displayCommand.value &&
|
|
['finished', 'interrupted', 'expired', 'error'].includes(sessionStatus.value)
|
|
) {
|
|
return 'Run again';
|
|
}
|
|
|
|
return 'Run command';
|
|
});
|
|
const sessionErrorTitle = computed(() => {
|
|
if (typeof sessionExitCode.value === 'number') {
|
|
return 'Command failed';
|
|
}
|
|
|
|
return hasActiveSession.value ? 'Command stream failed' : 'Command request failed';
|
|
});
|
|
const sessionStatusLabel = computed(() => {
|
|
switch (sessionStatus.value) {
|
|
case 'starting':
|
|
return 'Starting';
|
|
|
|
case 'running':
|
|
return 'Streaming';
|
|
|
|
case 'reconnecting':
|
|
return 'Reconnecting';
|
|
|
|
case 'finished':
|
|
return sessionExitCode.value === 0 ? 'Finished' : 'Failed';
|
|
|
|
case 'interrupted':
|
|
return 'Interrupted';
|
|
|
|
case 'expired':
|
|
return 'Expired';
|
|
|
|
case 'error':
|
|
return 'Failed';
|
|
|
|
default:
|
|
return 'Idle';
|
|
}
|
|
});
|
|
const sessionStatusColor = computed(() => {
|
|
switch (sessionStatus.value) {
|
|
case 'starting':
|
|
case 'running':
|
|
return 'info';
|
|
|
|
case 'reconnecting':
|
|
case 'interrupted':
|
|
case 'expired':
|
|
return 'warning';
|
|
|
|
case 'finished':
|
|
return sessionExitCode.value === 0 ? 'success' : 'error';
|
|
|
|
case 'error':
|
|
return 'error';
|
|
|
|
default:
|
|
return 'neutral';
|
|
}
|
|
});
|
|
const sessionStatusIcon = computed(() => {
|
|
switch (sessionStatus.value) {
|
|
case 'starting':
|
|
case 'running':
|
|
case 'reconnecting':
|
|
return 'i-lucide-loader-circle';
|
|
|
|
case 'finished':
|
|
return sessionExitCode.value === 0 ? 'i-lucide-circle-check' : 'i-lucide-triangle-alert';
|
|
|
|
case 'interrupted':
|
|
return 'i-lucide-circle-off';
|
|
|
|
case 'expired':
|
|
return 'i-lucide-clock-3';
|
|
|
|
case 'error':
|
|
return 'i-lucide-triangle-alert';
|
|
|
|
default:
|
|
return 'i-lucide-circle-dot';
|
|
}
|
|
});
|
|
const sessionStatusSpinning = computed(() => ACTIVE_SESSION_STATUSES.includes(sessionStatus.value));
|
|
|
|
const isRecentSessionFailed = (item: (typeof recentSessions.value)[number]): boolean => {
|
|
return item.status === 'completed' && item.exitCode !== null && item.exitCode !== 0;
|
|
};
|
|
|
|
const recentSessionStatusLabel = (item: (typeof recentSessions.value)[number]): string => {
|
|
if (isRecentSessionFailed(item)) {
|
|
return 'Failed';
|
|
}
|
|
|
|
switch (item.status) {
|
|
case 'starting':
|
|
return 'Starting';
|
|
|
|
case 'running':
|
|
return 'Running';
|
|
|
|
default:
|
|
return 'Completed';
|
|
}
|
|
};
|
|
|
|
const recentSessionStatusColor = (
|
|
item: (typeof recentSessions.value)[number],
|
|
): 'success' | 'error' | 'info' | 'neutral' => {
|
|
if (isRecentSessionFailed(item)) {
|
|
return 'error';
|
|
}
|
|
|
|
switch (item.status) {
|
|
case 'starting':
|
|
case 'running':
|
|
return 'info';
|
|
|
|
default:
|
|
return item.exitCode === 0 ? 'success' : 'neutral';
|
|
}
|
|
};
|
|
|
|
const recentSessionStatusIcon = (item: (typeof recentSessions.value)[number]): string => {
|
|
if (isRecentSessionFailed(item)) {
|
|
return 'i-lucide-triangle-alert';
|
|
}
|
|
|
|
switch (item.status) {
|
|
case 'starting':
|
|
case 'running':
|
|
return 'i-lucide-loader-circle';
|
|
|
|
default:
|
|
return item.exitCode === 0 ? 'i-lucide-circle-check' : 'i-lucide-circle-dot';
|
|
}
|
|
};
|
|
|
|
const recentSessionStatusSpinning = (item: (typeof recentSessions.value)[number]): boolean => {
|
|
return ['starting', 'running'].includes(item.status);
|
|
};
|
|
watch(command, (value) => {
|
|
storedCommand.value = value;
|
|
});
|
|
|
|
watch(
|
|
() => sessionStatus.value,
|
|
async (value, oldValue) => {
|
|
const sessionCommand = consoleSession.state.value.command.trim();
|
|
if (sessionCommand && (!command.value.trim() || ['expired', 'interrupted'].includes(value))) {
|
|
command.value = sessionCommand;
|
|
}
|
|
|
|
if (
|
|
oldValue &&
|
|
oldValue !== value &&
|
|
ACTIVE_SESSION_STATUSES.includes(oldValue) &&
|
|
!ACTIVE_SESSION_STATUSES.includes(value)
|
|
) {
|
|
await nextTick();
|
|
await focusInput();
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => sessionError.value,
|
|
(value, oldValue) => {
|
|
if (!value || value === oldValue) {
|
|
return;
|
|
}
|
|
|
|
toast.error(value);
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => bufferedTranscript.value.length,
|
|
(length, previousLength = 0) => {
|
|
if (!terminal.value) {
|
|
return;
|
|
}
|
|
|
|
if (!didInitialRender) {
|
|
restoreBufferedTerminalOutput();
|
|
return;
|
|
}
|
|
|
|
if (length < previousLength) {
|
|
restoreBufferedTerminalOutput();
|
|
return;
|
|
}
|
|
|
|
scheduleFlush();
|
|
},
|
|
);
|
|
|
|
watch(isMultiLineInput, async (value, oldValue) => {
|
|
if (value === oldValue) {
|
|
return;
|
|
}
|
|
|
|
await focusInput();
|
|
});
|
|
|
|
watch(
|
|
() => config.app.console_enabled,
|
|
async () => {
|
|
if (config.app.console_enabled) {
|
|
return;
|
|
}
|
|
|
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
|
await navigateTo('/');
|
|
},
|
|
);
|
|
|
|
const getInputElement = (): HTMLInputElement | HTMLTextAreaElement | null => {
|
|
const element = isMultiLineInput.value
|
|
? commandTextarea.value?.$el?.querySelector('textarea')
|
|
: commandInput.value?.$el?.querySelector('input');
|
|
|
|
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
return element;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const focusInput = async (): Promise<void> => {
|
|
await nextTick();
|
|
getInputElement()?.focus();
|
|
};
|
|
|
|
const scheduleTerminalFit = (): void => {
|
|
if (!terminal.value || !terminalFit.value || fitFrame) {
|
|
return;
|
|
}
|
|
|
|
fitFrame = window.requestAnimationFrame(() => {
|
|
fitFrame = null;
|
|
|
|
if (!terminal.value || !terminalFit.value) {
|
|
return;
|
|
}
|
|
|
|
terminalFit.value.fit();
|
|
});
|
|
};
|
|
|
|
const bindTerminalResizeObserver = (): void => {
|
|
if (!terminal_window.value || typeof ResizeObserver === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
terminalResizeObserver?.disconnect();
|
|
terminalResizeObserver = new ResizeObserver(() => {
|
|
scheduleTerminalFit();
|
|
});
|
|
terminalResizeObserver.observe(terminal_window.value);
|
|
};
|
|
|
|
const restoreBufferedTerminalOutput = (): void => {
|
|
if (!terminal.value) {
|
|
return;
|
|
}
|
|
|
|
terminal.value.clear();
|
|
renderedChunkCount = 0;
|
|
|
|
if (bufferedTranscript.value.length < 1) {
|
|
didInitialRender = true;
|
|
scheduleTerminalFit();
|
|
return;
|
|
}
|
|
|
|
terminal.value.write(bufferedTranscript.value.join(''));
|
|
renderedChunkCount = bufferedTranscript.value.length;
|
|
didInitialRender = true;
|
|
scheduleTerminalFit();
|
|
|
|
window.requestAnimationFrame(() => {
|
|
scheduleTerminalFit();
|
|
});
|
|
};
|
|
|
|
const flushTerminal = (): void => {
|
|
if (!terminal.value) {
|
|
return;
|
|
}
|
|
|
|
if (!didInitialRender) {
|
|
didInitialRender = true;
|
|
}
|
|
|
|
if (bufferedTranscript.value.length < renderedChunkCount) {
|
|
restoreBufferedTerminalOutput();
|
|
return;
|
|
}
|
|
|
|
if (bufferedTranscript.value.length === renderedChunkCount) {
|
|
return;
|
|
}
|
|
|
|
const chunk = bufferedTranscript.value.slice(renderedChunkCount).join('');
|
|
if (!chunk) {
|
|
return;
|
|
}
|
|
|
|
terminal.value.write(chunk);
|
|
renderedChunkCount = bufferedTranscript.value.length;
|
|
};
|
|
|
|
const scheduleFlush = (): void => {
|
|
if (flushFrame) {
|
|
return;
|
|
}
|
|
|
|
flushFrame = window.requestAnimationFrame(() => {
|
|
flushFrame = null;
|
|
flushTerminal();
|
|
});
|
|
};
|
|
|
|
const handleTerminalResize = (): void => {
|
|
scheduleTerminalFit();
|
|
};
|
|
|
|
const handlePageLeave = (): void => {
|
|
consoleSession.disconnect();
|
|
};
|
|
|
|
const ensureTerminal = async (): Promise<void> => {
|
|
if (terminal.value) {
|
|
return;
|
|
}
|
|
|
|
terminalFit.value = new FitAddon();
|
|
terminal.value = new Terminal({
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
cursorBlink: false,
|
|
cursorStyle: 'underline',
|
|
convertEol: true,
|
|
disableStdin: true,
|
|
scrollback: 2000,
|
|
theme: {
|
|
background: '#09090b',
|
|
foreground: '#f4f4f5',
|
|
cursor: '#60a5fa',
|
|
selectionBackground: 'rgba(255, 255, 255, 0.18)',
|
|
},
|
|
});
|
|
|
|
terminal.value.loadAddon(terminalFit.value);
|
|
|
|
await nextTick();
|
|
|
|
if (terminal_window.value) {
|
|
terminal.value.open(terminal_window.value);
|
|
}
|
|
|
|
bindTerminalResizeObserver();
|
|
scheduleTerminalFit();
|
|
};
|
|
|
|
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
|
if (event.defaultPrevented || event.isComposing || event.key !== 'Enter') {
|
|
return;
|
|
}
|
|
|
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
|
const isTextarea = target.tagName === 'TEXTAREA';
|
|
|
|
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
|
event.preventDefault();
|
|
await runCommand();
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey && !isTextarea) {
|
|
event.preventDefault();
|
|
const cursorPos = target.selectionStart || command.value.length;
|
|
command.value =
|
|
command.value.substring(0, cursorPos) +
|
|
'\n' +
|
|
command.value.substring(target.selectionEnd || cursorPos);
|
|
|
|
await nextTick();
|
|
|
|
const textarea = commandTextarea.value?.$el?.querySelector('textarea');
|
|
if (textarea instanceof HTMLTextAreaElement) {
|
|
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
|
textarea.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
const pastedText = event.clipboardData?.getData('text') || '';
|
|
if (!pastedText.includes('\n')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const target = event.target as HTMLInputElement;
|
|
const currentValue = command.value || '';
|
|
const start = target.selectionStart || currentValue.length;
|
|
const end = target.selectionEnd || currentValue.length;
|
|
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end);
|
|
|
|
await nextTick();
|
|
|
|
const textarea = commandTextarea.value?.$el?.querySelector('textarea');
|
|
if (textarea instanceof HTMLTextAreaElement) {
|
|
const newPos = start + pastedText.length;
|
|
textarea.setSelectionRange(newPos, newPos);
|
|
textarea.focus();
|
|
}
|
|
};
|
|
|
|
const runCommand = async (): Promise<void> => {
|
|
if (!canStartCommand.value) {
|
|
return;
|
|
}
|
|
|
|
if (config.app.console_enabled !== true) {
|
|
await navigateTo('/');
|
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
|
return;
|
|
}
|
|
|
|
if (displayCommand.value !== command.value.trim()) {
|
|
command.value = displayCommand.value;
|
|
await nextTick();
|
|
}
|
|
|
|
if (runnableCommand.value === 'clear') {
|
|
await clearOutput(true);
|
|
return;
|
|
}
|
|
|
|
await ensureTerminal();
|
|
|
|
const started = await consoleSession.startSession({
|
|
command: runnableCommand.value,
|
|
displayCommand: displayCommand.value,
|
|
});
|
|
|
|
if (started) {
|
|
storedCommand.value = '';
|
|
}
|
|
};
|
|
|
|
const runHelp = async (): Promise<void> => {
|
|
if (isStartBlocked.value) {
|
|
return;
|
|
}
|
|
|
|
command.value = '--help';
|
|
await nextTick();
|
|
await runCommand();
|
|
};
|
|
|
|
const reconnectSession = async (): Promise<void> => {
|
|
if (!canManualReconnect.value || manualReconnectPending.value) {
|
|
return;
|
|
}
|
|
|
|
manualReconnectPending.value = true;
|
|
|
|
try {
|
|
await consoleSession.restoreSession();
|
|
restoreBufferedTerminalOutput();
|
|
} finally {
|
|
manualReconnectPending.value = false;
|
|
await focusInput();
|
|
}
|
|
};
|
|
|
|
const cancelCommand = async (): Promise<void> => {
|
|
if (!showCancelButton.value || cancelPending.value) {
|
|
return;
|
|
}
|
|
|
|
cancelPending.value = true;
|
|
|
|
try {
|
|
const result = await consoleSession.cancelSession();
|
|
if (result.status === 'error' && result.message) {
|
|
toast.error(result.message);
|
|
}
|
|
} finally {
|
|
cancelPending.value = false;
|
|
await focusInput();
|
|
}
|
|
};
|
|
|
|
const clearOutput = async (withCommand: boolean = false): Promise<void> => {
|
|
consoleSession.clearTranscript();
|
|
terminal.value?.clear();
|
|
renderedChunkCount = 0;
|
|
didInitialRender = true;
|
|
|
|
if (withCommand) {
|
|
command.value = '';
|
|
storedCommand.value = '';
|
|
}
|
|
|
|
await focusInput();
|
|
};
|
|
|
|
const loadCommand = async (cmd: string): Promise<void> => {
|
|
command.value = cmd;
|
|
await nextTick();
|
|
await focusInput();
|
|
};
|
|
|
|
const hideRecentSession = (sessionId: string): void => {
|
|
consoleSession.hideRecentSession(sessionId);
|
|
};
|
|
|
|
const hideRecentSessions = async (): Promise<void> => {
|
|
if (recentSessionEntries.value.length < 1) {
|
|
return;
|
|
}
|
|
|
|
const { status } = await dialog.confirmDialog({
|
|
title: 'Confirm Action',
|
|
message: 'Hide the current recent runs from this browser?',
|
|
confirmText: 'Hide runs',
|
|
confirmColor: 'error',
|
|
});
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
consoleSession.clearRecentSessions();
|
|
await focusInput();
|
|
};
|
|
|
|
const replayRecentSession = async (item: (typeof recentSessions.value)[number]): Promise<void> => {
|
|
try {
|
|
await consoleSession.replaySession(item);
|
|
restoreBufferedTerminalOutput();
|
|
} catch {
|
|
consoleSession.hideRecentSession(item.sessionId);
|
|
} finally {
|
|
await nextTick();
|
|
await focusInput();
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
if (config.app.console_enabled !== true) {
|
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
|
await navigateTo('/');
|
|
return;
|
|
}
|
|
|
|
window.addEventListener('resize', handleTerminalResize);
|
|
window.addEventListener('pagehide', handlePageLeave);
|
|
window.addEventListener('beforeunload', handlePageLeave);
|
|
disableOpacity();
|
|
|
|
await ensureTerminal();
|
|
await consoleSession.restoreSession();
|
|
await consoleSession.fetchRecentSessions();
|
|
restoreBufferedTerminalOutput();
|
|
|
|
if (storedCommand.value.trim()) {
|
|
command.value = storedCommand.value;
|
|
await nextTick();
|
|
} else if (consoleSession.state.value.command.trim()) {
|
|
command.value = consoleSession.state.value.command;
|
|
await nextTick();
|
|
}
|
|
|
|
await focusInput();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
consoleSession.disconnect();
|
|
window.removeEventListener('pagehide', handlePageLeave);
|
|
window.removeEventListener('beforeunload', handlePageLeave);
|
|
terminalResizeObserver?.disconnect();
|
|
terminalResizeObserver = null;
|
|
|
|
if (flushFrame) {
|
|
window.cancelAnimationFrame(flushFrame);
|
|
flushFrame = null;
|
|
}
|
|
|
|
if (fitFrame) {
|
|
window.cancelAnimationFrame(fitFrame);
|
|
fitFrame = null;
|
|
}
|
|
|
|
terminal.value?.dispose();
|
|
terminal.value = null;
|
|
terminalFit.value = null;
|
|
didInitialRender = false;
|
|
renderedChunkCount = 0;
|
|
manualReconnectPending.value = false;
|
|
cancelPending.value = false;
|
|
|
|
window.removeEventListener('resize', handleTerminalResize);
|
|
enableOpacity();
|
|
});
|
|
</script>
|