Add a Terminal (#49)

This implements a simple terminal frontend using Xterm and backend using portable_pty. It will not yet record, but it should keep a separate terminal per project, resize properly, change directories to the project, etc.

https://docs.rs/portable-pty/latest/portable_pty/
This commit is contained in:
Scott Chacon 2023-04-05 10:10:07 +02:00 committed by GitHub
parent 79632f5ce9
commit 35f28cbc94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1759 additions and 638 deletions

3
.gitignore vendored
View File

@ -31,6 +31,9 @@ vite.config.js.timestamp-*
# gitbutler # gitbutler
.git/gb-* .git/gb-*
src-tauri/data.txt
# storybook # storybook
storybook-static storybook-static

View File

@ -50,8 +50,15 @@
"nanoid": "^4.0.1", "nanoid": "^4.0.1",
"posthog-js": "^1.46.1", "posthog-js": "^1.46.1",
"svelte-french-toast": "^1.0.3", "svelte-french-toast": "^1.0.3",
"svelte-resize-observer": "^2.0.0",
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log", "tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log",
"tinykeys": "^1.4.0" "tinykeys": "^1.4.0",
"xterm": "^5.1.0",
"xterm-addon-canvas": "^0.3.0",
"xterm-addon-fit": "^0.7.0",
"xterm-addon-ligatures": "^0.6.0",
"xterm-addon-unicode11": "^0.5.0",
"xterm-addon-webgl": "^0.14.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-essentials": "next", "@storybook/addon-essentials": "next",

File diff suppressed because it is too large Load Diff

1124
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,13 @@ thiserror = "1.0.38"
tantivy = "0.19.2" tantivy = "0.19.2"
similar = "2.2.1" similar = "2.2.1"
fslock = "0.2.1" fslock = "0.2.1"
tokio = { version = "1.26.0", features = ["sync"] } tokio = { version = "1.26.0", features = ["full", "sync"] }
tokio-tungstenite = "0.18.0"
portable-pty = "0.8.0"
mt_logger = "3.0.2"
bytes = "1.1.0"
futures = "0.3"
futures-util = "0.3.8"
timed = "0.2.1" timed = "0.2.1"
[features] [features]

View File

@ -3,6 +3,7 @@ mod events;
mod fs; mod fs;
mod git; mod git;
mod projects; mod projects;
mod pty;
mod repositories; mod repositories;
mod search; mod search;
mod sessions; mod sessions;
@ -16,6 +17,7 @@ extern crate log;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use deltas::Delta; use deltas::Delta;
use git::activity; use git::activity;
use pty::ws_server::pty_serve;
use serde::{ser::SerializeMap, Serialize}; use serde::{ser::SerializeMap, Serialize};
use std::{collections::HashMap, ops::Range, path::Path, sync::Mutex}; use std::{collections::HashMap, ops::Range, path::Path, sync::Mutex};
use storage::Storage; use storage::Storage;
@ -724,6 +726,12 @@ fn init(app_handle: tauri::AppHandle) -> Result<()> {
log::error!("{}: failed to reindex project: {:#}", project.id, err); log::error!("{}: failed to reindex project: {:#}", project.id, err);
} }
} }
tauri::async_runtime::spawn(async move {
println!("starting pty server");
pty_serve().await;
});
watch_events(app_handle, rx); watch_events(app_handle, rx);
Ok(()) Ok(())

1
src-tauri/src/pty/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod ws_server;

View File

@ -0,0 +1,227 @@
use std::env;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::PathBuf;
use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use mt_logger::*;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
const PTY_SERVER_ADDRESS: &str = "127.0.0.1:7703";
const TERM: &str = "xterm-256color";
#[derive(Deserialize, Debug)]
struct WindowSize {
/// The number of lines of text
pub rows: u16,
/// The number of columns of text
pub cols: u16,
/// The width of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_width: u16,
/// The height of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_height: u16,
}
async fn handle_client(stream: TcpStream) {
let ws_stream = accept_async(stream).await.expect("Failed to accept");
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let pty_system = native_pty_system();
let pty_pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
// Not all systems support pixel_width, pixel_height,
// but it is good practice to set it to something
// that matches the size of the selected font. That
// is more complex than can be shown here in this
// brief example though!
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let cmd = if cfg!(target_os = "windows") {
// CommandBuilder::new(r"powershell")
// CommandBuilder::new(r"C:\Program Files\Git\bin\bash.exe")
// CommandBuilder::new(r"ubuntu.exe") // if WSL is active
// on UI the user should have the option to choose
let mut cmd = CommandBuilder::new(r"cmd");
// this is needed only for cmd.exe
// because the prompt does not have an empty space at the end
// the prompt should be sepratared from the command being typed, for command parsing
cmd.env("PROMPT", "$P$G ");
cmd
} else {
let user_default_shell = env::var("SHELL").unwrap();
mt_log!(Level::Info, "user_default_shell={}", user_default_shell);
let user_scripts = &Value::Null;
let scripts = json!({
"cwd": "$(pwd)",
"user_scripts": user_scripts
});
let scripts = scripts.to_string();
let scripts_str = serde_json::to_string(&scripts).unwrap();
mt_log!(Level::Info, "scripts={}", scripts_str);
let prompt_command_scripts = format!(r#"echo -en "\033]0; [manter] "{}" \a""#, scripts_str);
let mut cmd = CommandBuilder::new(user_default_shell);
cmd.env("PROMPT_COMMAND", prompt_command_scripts);
cmd.env("TERM", TERM);
cmd.args(["-i"]);
cmd
};
let mut pty_child_process = pty_pair.slave.spawn_command(cmd).unwrap();
let mut pty_reader = pty_pair.master.try_clone_reader().unwrap();
let mut pty_writer = pty_pair.master.take_writer().unwrap();
// set to cwd
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut buffer = BytesMut::with_capacity(1024);
buffer.resize(1024, 0u8);
loop {
buffer[0] = 0u8;
let mut tail = &mut buffer[1..];
match pty_reader.read(&mut tail) {
Ok(0) => {
// EOF
mt_log!(Level::Info, "0 bytes read from pty. EOF.");
break;
}
Ok(n) => {
if n == 0 {
// this may be redundant because of Ok(0), but not sure
break;
}
let mut data_to_send = Vec::with_capacity(n + 1);
data_to_send.extend_from_slice(&buffer[..n + 1]);
record_data(&data_to_send);
let message = Message::Binary(data_to_send);
ws_sender.send(message).await.unwrap();
}
Err(e) => {
mt_log!(Level::Error, "Error reading from pty: {}", e);
mt_log!(Level::Error, "PTY child process may be closed.");
break;
}
}
}
mt_log!(Level::Info, "PTY child process killed.");
})
});
while let Some(message) = ws_receiver.next().await {
let message = message.unwrap();
match message {
Message::Binary(msg) => {
let msg_bytes = msg.as_slice();
match msg_bytes[0] {
0 => {
if msg_bytes.len().gt(&0) {
record_data(&msg);
pty_writer.write_all(&msg_bytes[1..]).unwrap();
}
}
1 => {
let resize_msg: WindowSize =
serde_json::from_slice(&msg_bytes[1..]).unwrap();
let pty_size = PtySize {
rows: resize_msg.rows,
cols: resize_msg.cols,
pixel_width: resize_msg.pixel_width,
pixel_height: resize_msg.pixel_height,
};
pty_pair.master.resize(pty_size).unwrap();
}
2 => {
// takes the directory we should be recording data to
if msg_bytes.len().gt(&0) {
// convert bytes to string
let command = String::from_utf8_lossy(&msg_bytes[1..]);
let project_path = PathBuf::from(command.as_ref());
println!("Recording to {:?}", project_path);
}
}
_ => mt_log!(Level::Error, "Unknown command {}", msg_bytes[0]),
}
}
Message::Close(_) => {
mt_log!(Level::Info, "Closing the websocket connection...");
mt_log!(Level::Info, "Killing PTY child process...");
pty_child_process.kill().unwrap();
mt_log!(Level::Info, "Breakes the loop. This will terminate the ws socket thread and the ws will close");
break;
}
_ => mt_log!(Level::Error, "Unknown received data type"),
}
}
mt_log!(
Level::Info,
"The Websocket was closed and the thread for WS listening will end soon."
);
}
// this sort of works, but it's not how we want to do it
// it just appends the data from every pty to the same file
// what we want to do is set the directory to record to, but since
// the reader is in a spawe thread, it's difficult to pass the directory to it
// I also can't seem to send data to the pty on opening a new one, so I can't
// easily initialize the cwd, which is where we want to write this data (under .git)
// HELP
fn record_data(data: &Vec<u8>) {
/*
// A little too aggressive:
let mut file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open("data.txt")
.unwrap();
file.write_all(data).unwrap();
*/
}
pub async fn pty_serve() {
let listener = TcpListener::bind(PTY_SERVER_ADDRESS)
.await
.expect("Can't listen");
while let Ok((stream, _)) = listener.accept().await {
let peer = stream
.peer_addr()
.expect("connected streams should have a peer address");
mt_log!(Level::Info, "Peer address: {}", peer);
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
handle_client(stream).await;
});
});
}
}

View File

@ -0,0 +1,39 @@
<script lang="ts">
import 'xterm/css/xterm.css';
import ResizeObserver from 'svelte-resize-observer';
import * as terminals from '$lib/terminals';
import { onMount } from 'svelte';
export let session: terminals.TerminalSession;
onMount(() => {
if (session.element) {
session.controller?.open(session.element);
}
});
function handleTermResize() {
terminals.fitSession(session);
}
export const runCommand = (command: string): void => {
if (session.pty) {
command = command + '\r';
console.log('command input', command);
const encodedData = new TextEncoder().encode('\x00' + command);
session.pty.send(encodedData);
}
};
</script>
<!-- Actual terminal -->
<div class="flex h-full w-full flex-row">
<div
id="terminal"
class="h-full w-full"
bind:this={session.element}
on:click={focus}
on:keydown={focus}
/>
<ResizeObserver on:resize={handleTermResize} />
</div>

165
src/lib/terminals.ts Normal file
View File

@ -0,0 +1,165 @@
import { writable } from 'svelte/store';
import type { Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import * as xterm from 'xterm';
import * as fit from 'xterm-addon-fit';
import { CanvasAddon } from 'xterm-addon-canvas';
import { Unicode11Addon } from 'xterm-addon-unicode11';
const PTY_WS_ADDRESS = 'ws://127.0.0.1:7703';
export type TerminalSession = {
projectId: string;
path: string;
element: HTMLElement | null;
controller: Terminal | null;
fit: FitAddon | null;
pty: WebSocket | null;
};
export const terminals = writable<Record<string, TerminalSession>>({});
export const getTerminalSession = (projectId: string, projectPath: string) => {
let object: TerminalSession | undefined;
terminals.subscribe((terms) => {
object = terms[projectId];
});
if (!object) {
object = {
projectId: projectId,
path: projectPath,
element: null,
controller: null,
fit: null,
pty: null
} as TerminalSession;
newTerminalSession(object);
updateStore(object);
}
return object;
};
function updateStore(session: TerminalSession) {
terminals.update((terms) => {
terms[session.projectId] = session;
return terms;
});
}
export const newTerminalSession = async (session: TerminalSession) => {
session.pty = new WebSocket(PTY_WS_ADDRESS);
session.pty.binaryType = 'arraybuffer';
session.pty.onmessage = (evt) => writePtyIncomingToTermInterface(evt, session);
session.pty.onclose = (evt) => handlePtyWsClose(evt, session);
session.pty.onerror = (evt) => handlePtyWsError(evt, session);
session.pty.onopen = async (_evt) => initalizeXterm(session);
};
export function focus(session: TerminalSession) {
console.log('focus');
//session.controller.focus();
}
function initalizeXterm(session: TerminalSession) {
console.log('initalizeXterm');
session.controller = new xterm.Terminal({
cursorBlink: false,
cursorStyle: 'block',
fontSize: 13,
rows: 24,
cols: 80,
allowProposedApi: true
});
session.controller.loadAddon(new Unicode11Addon());
session.controller.unicode.activeVersion = '11';
session.fit = new fit.FitAddon();
session.controller.loadAddon(session.fit);
session.controller.loadAddon(new CanvasAddon());
if (session.element) {
session.controller.open(session.element);
}
fitSession(session);
session.controller.onData((data) => termInterfaceHandleUserInputData(data, session));
sendPathToPty(session);
updateStore(session);
focus(session);
}
const writePtyIncomingToTermInterface = (evt: MessageEvent, session: TerminalSession) => {
if (!(evt.data instanceof ArrayBuffer)) {
alert('unknown data type ' + evt.data);
return;
}
//console.log('terminal input', evt.data);
const dataString: string = arrayBufferToString(evt.data.slice(1));
//console.log('terminal input string', dataString);
if (session.controller) {
session.controller.write(dataString);
}
return dataString;
};
const termInterfaceHandleUserInputData = (data: string, session: TerminalSession) => {
console.log('user input', data);
const encodedData = new TextEncoder().encode('\x00' + data);
if (session.pty) {
session.pty.send(encodedData);
}
};
export const fitSession = (session: TerminalSession) => {
if (session.fit) {
session.fit.fit();
}
sendProposedSizeToPty(session);
};
const sendProposedSizeToPty = (session: TerminalSession) => {
if (session.fit && session.pty) {
const proposedSize = session.fit.proposeDimensions();
if (!proposedSize) return;
const resizeData = {
cols: proposedSize.cols,
rows: proposedSize.rows,
pixel_width: 0,
pixel_height: 0
};
session.pty.send(new TextEncoder().encode('\x01' + JSON.stringify(resizeData)));
}
};
// this is a pretty stupid cheat, but it works on unix systems
const sendPathToPty = (session: TerminalSession) => {
if (!session.pty) return;
// send the path so th pty knows where to record data
const encodedPath = new TextEncoder().encode('\x02' + session.path);
session.pty.send(encodedPath);
// send a command to change the directory and clear the screen
const encodedData = new TextEncoder().encode('\x00' + 'cd ' + session.path + ';clear\n');
session.pty.send(encodedData);
};
const arrayBufferToString = (buf: ArrayBuffer) => {
return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)));
};
const handlePtyWsClose = (evt: Event, session: TerminalSession) => {
if (session.controller) {
session.controller.write('Terminal session terminated');
session.controller.dispose();
console.log('websocket closes from backend side');
}
};
const handlePtyWsError = (evt: Event, session: TerminalSession) => {
if (typeof console.log == 'function') {
console.log('ws error', evt);
}
};

View File

@ -69,6 +69,22 @@
> >
&#8984;K &#8984;K
</div> </div>
<a href="/projects/{$project?.id}/terminal">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
</svg>
</a>
</div> </div>
<ul> <ul>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { collapsable } from '$lib/paths';
import type { LayoutData } from '../$types';
import Terminal from '$lib/components/Terminal.svelte';
import * as terminals from '$lib/terminals';
export let data: LayoutData;
const { project, statuses } = data;
let terminal: Terminal;
let terminalSession: terminals.TerminalSession;
$: if ($project) {
console.log($project);
terminalSession = terminals.getTerminalSession($project.id, $project.path);
console.log('session', terminalSession);
}
function runCommand(command: string) {
terminal.runCommand(command);
}
</script>
<!-- Actual terminal -->
<div class="flex h-full w-full flex-row">
<div class="h-full w-80 p-2">
<div class="p-2 font-bold">Git Status</div>
{#if $statuses.length == 0}
<div class="rounded border border-green-400 bg-green-600 p-2 font-mono text-green-900">
No changes
</div>
{:else}
<ul class="rounded border border-yellow-400 bg-yellow-500 p-2 font-mono text-yellow-900">
{#each $statuses as activity}
<li class="flex w-full gap-2">
<span
class:text-left={activity.staged}
class:text-right={!activity.staged}
class="w-[3ch] font-semibold">{activity.status.slice(0, 1).toUpperCase()}</span
>
<span class="truncate" use:collapsable={{ value: activity.path, separator: '/' }} />
</li>
{/each}
</ul>
{/if}
<div class="mt-4 p-2 font-bold">Commands</div>
<ul class="px-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li class="cursor-pointer" on:click={() => runCommand('git push')}>Push Commit</li>
</ul>
</div>
<div class="h-full w-full">
{#if terminalSession}
<Terminal session={terminalSession} bind:this={terminal} />
{/if}
</div>
</div>

View File

@ -0,0 +1,25 @@
import { readable } from 'svelte/store';
import type { Status } from '$lib/git/statuses';
import { building } from '$app/environment';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => {
const statuses = building
? readable<Status[]>([])
: await import('$lib/git/statuses').then((m) => m.default({ projectId: params.projectId }));
const user = building
? {
...readable<undefined>(undefined),
set: () => {
throw new Error('not implemented');
},
delete: () => {
throw new Error('not implemented');
}
}
: await (await import('$lib/users')).default();
return {
user,
statuses
};
};