File size: 6,511 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import Prism from 'prismjs/components/prism-core.js';
import 'prismjs/components/prism-bash.js';
import { stripTerminalSequences } from '@earendil-works/pi-tui/dist/utils.js';

Prism.manual = true;
const plain = value => stripTerminalSequences(String(value ?? '')).replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
const outputLimit = 24_000;
function displayText(text) {
  text = plain(text);
  return text.length > outputLimit ? text.slice(0, outputLimit) + '\n… output truncated; use head or grep to narrow it down.' : text;
}

// Tokens become DOM text nodes, never HTML supplied by a command or file.
function syntax(text) {
  const fragment = document.createDocumentFragment();
  function append(parent, token) {
    if (typeof token === 'string') { parent.append(document.createTextNode(token)); return; }
    if (Array.isArray(token)) { token.forEach(item => append(parent, item)); return; }
    const span = document.createElement('span');
    span.className = ['token', token.type, ...[token.alias ?? []].flat()].join(' ');
    append(span, token.content); parent.append(span);
  }
  // Very large pastes remain editable without synchronous grammar work.
  append(fragment, text.length > 8192 ? text : Prism.tokenize(text, Prism.languages.bash));
  return fragment;
}

export function createShellUI(element, { run }) {
  const find = selector => element.querySelector(selector);
  const input = find('#command'), highlight = find('#command-highlight');
  const output = find('#terminal-output'), form = find('#shell-form');
  const runButton = find('#shell-run'), clearButton = find('#shell-clear');
  const empty = output.firstElementChild.cloneNode(true);
  const history = [], entries = [];
  let historyIndex = 0, draft = '', running = false, busy = false;
  const atBottom = () => output.scrollHeight - output.scrollTop - output.clientHeight < 24;
  const scrollEnd = () => { output.scrollTop = output.scrollHeight; };
  function controls() {
    runButton.disabled = running || busy || !input.value.trim();
    runButton.textContent = running ? 'Running…' : 'Run';
    clearButton.disabled = running || entries.length === 0;
  }
  function renderInput() {
    highlight.replaceChildren(syntax(input.value), document.createTextNode('\n'));
    input.style.height = 'auto'; input.style.height = Math.min(104, Math.max(32, input.scrollHeight)) + 'px';
    highlight.scrollTop = input.scrollTop; highlight.scrollLeft = input.scrollLeft;
    controls();
  }
  function setInput(value) { input.value = value; renderInput(); input.setSelectionRange(value.length, value.length); }
  function clear() {
    if (running) return;
    entries.length = 0; output.replaceChildren(empty.cloneNode(true)); controls();
  }
  function appendOutput(entry, value, className) {
    if (!value) return;
    const pre = document.createElement('pre'); pre.className = className;
    pre.textContent = displayText(value); entry.append(pre);
  }
  form.onsubmit = async event => {
    event.preventDefault();
    const command = input.value;
    if (running || busy || !command.trim()) return;
    running = true;
    if (history.at(-1) !== command) history.push(command);
    if (history.length > 50) history.shift();
    historyIndex = history.length; draft = ''; setInput(''); input.focus();
    if (!entries.length) output.replaceChildren();
    const entry = document.createElement('section'); entry.className = 'shell-entry';
    const header = document.createElement('div'); header.className = 'shell-entry-heading';
    const prompt = document.createElement('span'); prompt.className = 'shell-prompt'; prompt.textContent = '$';
    const code = document.createElement('pre'); code.className = 'shell-code'; code.append(syntax(displayText(command)));
    const status = document.createElement('span'); status.className = 'shell-status'; status.textContent = 'Running…';
    header.append(prompt, code, status); entry.append(header); output.append(entry); entries.push(entry); scrollEnd();
    try {
      const result = await run(command);
      const follow = atBottom();
      appendOutput(entry, result.stdout, 'shell-stdout');
      appendOutput(entry, result.stderr, 'shell-stderr');
      status.classList.add(result.exitCode === 0 ? 'success' : 'error'); status.textContent = `[exit ${result.exitCode}]`;
      if (follow) scrollEnd();
    } catch (error) {
      const follow = atBottom();
      appendOutput(entry, error.message, 'shell-stderr'); status.classList.add('error'); status.textContent = '[error]';
      if (!input.value) setInput(command);
      if (follow) scrollEnd();
    } finally {
      // Keep complete recent command blocks, bounded independently of the FS.
      while (entries.length > 1 && (entries.length > 40 || output.textContent.length > 48_000)) entries.shift().remove();
      running = false; controls();
    }
  };
  input.addEventListener('input', () => { historyIndex = history.length; draft = input.value; renderInput(); });
  input.addEventListener('scroll', () => { highlight.scrollTop = input.scrollTop; highlight.scrollLeft = input.scrollLeft; });
  input.addEventListener('keydown', event => {
    if (event.isComposing || event.keyCode === 229) return;
    if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); form.requestSubmit(); return; }
    if (event.ctrlKey && event.key.toLowerCase() === 'l') { event.preventDefault(); clear(); return; }
    if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey || input.selectionStart !== input.selectionEnd) return;
    const firstLine = !input.value.slice(0, input.selectionStart).includes('\n');
    const lastLine = !input.value.slice(input.selectionEnd).includes('\n');
    if (event.key === 'ArrowUp' && firstLine && historyIndex > 0) {
      event.preventDefault(); if (historyIndex === history.length) draft = input.value;
      setInput(history[--historyIndex]);
    } else if (event.key === 'ArrowDown' && lastLine && historyIndex < history.length) {
      event.preventDefault(); setInput(++historyIndex === history.length ? draft : history[historyIndex]);
    }
  });
  clearButton.onclick = () => { clear(); input.focus(); };
  // Wrapping and textarea height change with the workspace width.
  let width = 0;
  new ResizeObserver(([entry]) => { if (entry.contentRect.width !== width) { width = entry.contentRect.width; renderInput(); } }).observe(input.parentElement);
  renderInput();
  return { setBusy(value) { busy = value; controls(); } };
}