Files
website/src/lib/components/terminal/Terminal.svelte
T

201 lines
4.7 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { profile } from '#lib/content';
import { openPage, openUrl } from '#lib/desktop/actions';
import { themeState } from '#lib/theme/theme.svelte';
import { runCommand, type CommandContext } from '#lib/terminal/commands';
import { complete } from '#lib/terminal/completion';
import { line, span, type OutputBlock } from '#lib/terminal/output';
import OutputLine from './OutputLine.svelte';
import Fetch from './Fetch.svelte';
let { startupCommand = '', focused, onexit }: { startupCommand?: string; focused: boolean; onexit: () => void } =
$props();
const typingDelay = 35;
let blocks = $state<OutputBlock[]>([]);
let input = $state('');
let isTyping = $state(false);
let history: string[] = [];
let historyIndex = 0;
let inputElement = $state<HTMLInputElement>();
let screenElement = $state<HTMLDivElement>();
const context: CommandContext = {
print: (...newBlocks) => blocks.push(...newBlocks),
clear: () => (blocks = []),
exit: onexit,
openPage,
openUrl,
setTheme: (themeId) => themeState.set(themeId),
currentThemeId: () => themeState.currentId
};
async function execute(command: string) {
blocks.push({ kind: 'prompt', command });
if (command.trim()) history.push(command);
historyIndex = history.length;
runCommand(command, context);
await tick();
screenElement?.scrollTo({ top: screenElement.scrollHeight });
}
async function typeOut(command: string) {
isTyping = true;
for (const character of command) {
input += character;
await new Promise((resolve) => setTimeout(resolve, typingDelay));
}
input = '';
isTyping = false;
await execute(command);
}
async function showCompletion() {
const result = complete(input);
input = result.input;
if (result.options.length === 0) return;
blocks.push({ kind: 'prompt', command: input }, line(span(result.options.join(' '), 'cyan')));
await tick();
screenElement?.scrollTo({ top: screenElement.scrollHeight });
}
function recallHistory(step: number) {
if (history.length === 0) return;
historyIndex = Math.min(history.length, Math.max(0, historyIndex + step));
input = history[historyIndex] ?? '';
}
function handleKeydown(event: KeyboardEvent) {
if (isTyping) {
event.preventDefault();
return;
}
if (event.key === 'Enter') {
const command = input;
input = '';
execute(command);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
recallHistory(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(1);
} else if (event.key === 'Tab') {
event.preventDefault();
showCompletion();
} else if (event.key === 'l' && event.ctrlKey) {
event.preventDefault();
context.clear();
}
}
function focusInput() {
if (!window.getSelection()?.toString()) inputElement?.focus({ preventScroll: true });
}
$effect(() => {
if (focused) inputElement?.focus({ preventScroll: true });
});
onMount(() => {
if (startupCommand) typeOut(startupCommand);
});
</script>
<div class="terminal" bind:this={screenElement} onclick={focusInput} role="presentation">
{#each blocks as block, index (index)}
{#if block.kind === 'prompt'}
<div class="prompt-line">
<span class="prompt">{profile.username}@{profile.hostname}</span>
<span class="path">~</span>
<span class="symbol">$</span>
<span>{block.command}</span>
</div>
{:else if block.kind === 'fetch'}
<Fetch />
{:else}
<OutputLine spans={block.spans} />
{/if}
{/each}
<label class="prompt-line input-line">
<span class="prompt">{profile.username}@{profile.hostname}</span>
<span class="path">~</span>
<span class="symbol">$</span>
<span class="input-wrapper">
<input
bind:this={inputElement}
bind:value={input}
onkeydown={handleKeydown}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
aria-label="Terminal input"
/>
</span>
</label>
</div>
<style>
.terminal {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem 1.1rem 1.5rem;
line-height: 1.5;
cursor: text;
}
.prompt-line {
display: flex;
gap: 0.5ch;
white-space: pre-wrap;
word-break: break-word;
}
.prompt {
color: var(--green);
font-weight: 700;
}
.path {
color: var(--blue);
font-weight: 700;
}
.symbol {
color: var(--accent);
}
.input-wrapper {
flex: 1;
display: flex;
}
input {
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
caret-color: var(--foreground);
}
@media (max-width: 640px) {
.terminal {
padding: 0.6rem 0.7rem 1.25rem;
font-size: 14px;
}
input {
font-size: 16px;
}
}
</style>