576 lines
17 KiB
Vue
576 lines
17 KiB
Vue
<template>
|
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
<div class="min-w-0 space-y-1">
|
|
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-terminal" class="size-5 text-toned" />
|
|
<span>Console</span>
|
|
|
|
<UBadge :color="isLoading ? 'info' : 'neutral'" variant="soft" size="sm">
|
|
{{ isLoading ? 'Streaming output' : 'Idle' }}
|
|
</UBadge>
|
|
|
|
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm">
|
|
{{ commandHistory.length }} saved
|
|
</UBadge>
|
|
</div>
|
|
|
|
<p class="text-sm text-toned">Run yt-dlp commands directly in a non-interactive session.</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-eraser"
|
|
@click="() => void clearOutput()"
|
|
>
|
|
Clear output
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<UPageCard variant="naked" :ui="pageCardUi">
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<div>
|
|
<div
|
|
ref="terminal_window"
|
|
class="terminal-host min-h-[55vh] max-h-[55vh] overflow-hidden rounded-xl scroll-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-default bg-default">
|
|
<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 space-y-1">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-send" class="size-4 text-toned" />
|
|
<span>Command</span>
|
|
</div>
|
|
|
|
<p class="text-xs text-toned">
|
|
Press <code>Enter</code> to run single-line input, <code>Shift+Enter</code> to
|
|
switch to multi-line, and <code>Ctrl+Enter</code> to run multi-line input.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-3 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
|
<div class="space-y-3">
|
|
<TextareaAutocomplete
|
|
v-if="isMultiLineInput"
|
|
ref="commandTextarea"
|
|
v-model="command"
|
|
:options="ytDlpOptions"
|
|
:disabled="isLoading"
|
|
placeholder="--help"
|
|
:rows="5"
|
|
@keydown="handleKeyDown"
|
|
/>
|
|
|
|
<InputAutocomplete
|
|
v-else
|
|
ref="commandInput"
|
|
v-model="command"
|
|
:options="ytDlpOptions"
|
|
:disabled="isLoading"
|
|
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)]" :ui="{ body: 'space-y-3 p-4' }">
|
|
<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>Command history</span>
|
|
</div>
|
|
|
|
<UButton
|
|
color="error"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-trash"
|
|
:disabled="commandHistory.length < 1"
|
|
@click="() => void clearHistory()"
|
|
>
|
|
Clear history
|
|
</UButton>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="commandHistory.length < 1"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-clock-3"
|
|
title="Commands history is empty"
|
|
/>
|
|
|
|
<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="min-w-155 w-full text-sm">
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="(cmd, index) in commandHistory"
|
|
:key="`${index}-${cmd}`"
|
|
class="hover:bg-muted/20"
|
|
>
|
|
<td class="px-3 py-3 align-middle">
|
|
<button
|
|
type="button"
|
|
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
|
|
@click="() => void loadCommand(cmd)"
|
|
>
|
|
{{ cmd.replace(/\n/g, ' ') }}
|
|
</button>
|
|
</td>
|
|
|
|
<td
|
|
class="w-12 px-3 py-3 text-center align-middle whitespace-nowrap"
|
|
>
|
|
<UButton
|
|
color="error"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-x"
|
|
square
|
|
@click="removeFromHistory(index)"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UPopover>
|
|
|
|
<UButton
|
|
color="primary"
|
|
size="lg"
|
|
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-send'"
|
|
:loading="isLoading"
|
|
:disabled="isLoading || !hasValidCommand"
|
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
|
@click="() => void runCommand()"
|
|
>
|
|
Run command
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UPageCard>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
|
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 { useDialog } from '~/composables/useDialog';
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils';
|
|
|
|
const config = useConfigStore();
|
|
const toast = useNotification();
|
|
const dialog = useDialog();
|
|
|
|
const terminal = ref<Terminal>();
|
|
const terminalFit = ref<FitAddon>();
|
|
const command = ref('');
|
|
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
|
|
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
|
|
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
|
const isLoading = ref(false);
|
|
const storedCommand = useStorage<string>('console_command', '');
|
|
const commandHistory = useStorage<string[]>('console_command_history', []);
|
|
const sseController = ref<AbortController | null>(null);
|
|
|
|
const MAX_HISTORY_ITEMS = 50;
|
|
|
|
const pageCardUi = {
|
|
root: 'w-full bg-transparent',
|
|
container: 'w-full p-0 sm:p-0',
|
|
wrapper: 'w-full items-stretch',
|
|
body: 'w-full',
|
|
};
|
|
|
|
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
|
config.ytdlp_options.flatMap((opt) =>
|
|
opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
|
|
),
|
|
);
|
|
|
|
const hasValidCommand = computed(() => Boolean(command.value && command.value.trim().length > 0));
|
|
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
|
|
|
|
watch(command, (value) => {
|
|
storedCommand.value = value;
|
|
});
|
|
|
|
watch(
|
|
() => isLoading.value,
|
|
async (value) => {
|
|
if (value) {
|
|
return;
|
|
}
|
|
|
|
if (command.value.trim()) {
|
|
addToHistory(command.value.trim());
|
|
}
|
|
|
|
command.value = '';
|
|
await nextTick();
|
|
await focusInput();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
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 handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
|
const isTextarea = target.tagName === 'TEXTAREA';
|
|
|
|
if (event.key !== 'Enter') {
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
if (commandTextarea.value) {
|
|
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
|
if (textarea) {
|
|
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
|
textarea.focus();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
|
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();
|
|
|
|
if (!commandTextarea.value) {
|
|
return;
|
|
}
|
|
|
|
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
|
if (textarea) {
|
|
const newPos = start + pastedText.length;
|
|
textarea.setSelectionRange(newPos, newPos);
|
|
textarea.focus();
|
|
}
|
|
};
|
|
|
|
const handle_event = (): void => {
|
|
terminalFit.value?.fit();
|
|
};
|
|
|
|
const handleStreamMessage = (event: EventSourceMessage): void => {
|
|
if (!terminal.value) {
|
|
return;
|
|
}
|
|
|
|
let payload: { type?: string; line?: string; exitcode?: number } | null = null;
|
|
if (event.data) {
|
|
try {
|
|
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number };
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
}
|
|
|
|
if (event.event === 'output') {
|
|
terminal.value.writeln(payload?.line ?? '');
|
|
return;
|
|
}
|
|
|
|
if (event.event === 'close') {
|
|
isLoading.value = false;
|
|
sseController.value?.abort();
|
|
}
|
|
};
|
|
|
|
const startStream = async (cmd: string): Promise<void> => {
|
|
sseController.value?.abort();
|
|
const controller = new AbortController();
|
|
sseController.value = controller;
|
|
isLoading.value = true;
|
|
|
|
try {
|
|
await fetchEventSource(uri('/api/system/terminal'), {
|
|
method: 'POST',
|
|
body: JSON.stringify({ command: cmd }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'text/event-stream',
|
|
},
|
|
credentials: 'same-origin',
|
|
signal: controller.signal,
|
|
onopen: async (response) => {
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
|
|
let message = response.statusText || 'Failed to start command stream.';
|
|
|
|
try {
|
|
message = await parse_api_error(response.clone().json());
|
|
} catch {
|
|
try {
|
|
const text = await response.text();
|
|
if (text) {
|
|
message = text;
|
|
}
|
|
} catch {
|
|
message = response.statusText || 'Failed to start command stream.';
|
|
}
|
|
}
|
|
|
|
throw new Error(message);
|
|
},
|
|
onmessage: handleStreamMessage,
|
|
onerror: (error) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
terminal.value?.writeln(`Error: ${error}`);
|
|
isLoading.value = false;
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
terminal.value?.writeln(`Error: ${error}`);
|
|
isLoading.value = false;
|
|
}
|
|
} finally {
|
|
if (controller === sseController.value) {
|
|
sseController.value = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const ensureTerminal = async (): Promise<void> => {
|
|
if (terminal.value) {
|
|
return;
|
|
}
|
|
|
|
terminal.value = new Terminal({
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
cursorBlink: false,
|
|
cursorStyle: 'underline',
|
|
cols: 108,
|
|
rows: 10,
|
|
disableStdin: true,
|
|
scrollback: 1000,
|
|
theme: {
|
|
background: '#09090b',
|
|
foreground: '#f4f4f5',
|
|
},
|
|
});
|
|
|
|
terminalFit.value = new FitAddon();
|
|
terminal.value.loadAddon(terminalFit.value);
|
|
|
|
await nextTick();
|
|
|
|
if (terminal_window.value) {
|
|
terminal.value.open(terminal_window.value);
|
|
}
|
|
|
|
terminalFit.value.fit();
|
|
};
|
|
|
|
const runCommand = async (): Promise<void> => {
|
|
if (!hasValidCommand.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;
|
|
}
|
|
|
|
let cmd = command.value.trim().replace(/\n/g, ' ').trim();
|
|
|
|
if (cmd.startsWith('yt-dlp')) {
|
|
cmd = cmd.replace(/^yt-dlp/, '').trim();
|
|
await nextTick();
|
|
if (cmd === '') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await ensureTerminal();
|
|
|
|
if (cmd === 'clear') {
|
|
await clearOutput(true);
|
|
return;
|
|
}
|
|
|
|
await startStream(cmd);
|
|
terminal.value?.writeln('user@YTPTube ~');
|
|
terminal.value?.writeln(`$ yt-dlp ${command.value}`);
|
|
storedCommand.value = '';
|
|
};
|
|
|
|
const clearOutput = async (withCommand: boolean = false): Promise<void> => {
|
|
terminal.value?.clear();
|
|
|
|
if (withCommand === true) {
|
|
command.value = '';
|
|
}
|
|
|
|
await focusInput();
|
|
};
|
|
|
|
const focusInput = async (): Promise<void> => {
|
|
await nextTick();
|
|
|
|
let elm: HTMLInputElement | HTMLTextAreaElement | undefined;
|
|
if (isMultiLineInput.value) {
|
|
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
|
} else {
|
|
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement;
|
|
}
|
|
|
|
elm?.focus();
|
|
};
|
|
|
|
const addToHistory = (cmd: string): void => {
|
|
commandHistory.value = [cmd, ...commandHistory.value.filter((h) => h !== cmd)].slice(
|
|
0,
|
|
MAX_HISTORY_ITEMS,
|
|
);
|
|
};
|
|
|
|
const loadCommand = async (cmd: string): Promise<void> => {
|
|
command.value = cmd;
|
|
await nextTick();
|
|
await focusInput();
|
|
};
|
|
|
|
const clearHistory = async (): Promise<void> => {
|
|
if (commandHistory.value.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const { status } = await dialog.confirmDialog({
|
|
title: 'Confirm Action',
|
|
message: 'Clear commands history?',
|
|
confirmColor: 'error',
|
|
});
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
commandHistory.value = [];
|
|
};
|
|
|
|
const removeFromHistory = (index: number): void => {
|
|
commandHistory.value = commandHistory.value.filter((_, i) => i !== index);
|
|
};
|
|
|
|
watch(isMultiLineInput, async () => {
|
|
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', handle_event);
|
|
disableOpacity();
|
|
|
|
await ensureTerminal();
|
|
|
|
if (storedCommand.value) {
|
|
command.value = storedCommand.value;
|
|
await nextTick();
|
|
}
|
|
|
|
await focusInput();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
sseController.value?.abort();
|
|
window.removeEventListener('resize', handle_event);
|
|
enableOpacity();
|
|
});
|
|
</script>
|