201 lines
4.7 KiB
Svelte
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>
|