mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2025-01-03 15:06:01 +03:00
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:
parent
79632f5ce9
commit
35f28cbc94
3
.gitignore
vendored
3
.gitignore
vendored
@ -31,6 +31,9 @@ vite.config.js.timestamp-*
|
|||||||
|
|
||||||
# gitbutler
|
# gitbutler
|
||||||
.git/gb-*
|
.git/gb-*
|
||||||
|
src-tauri/data.txt
|
||||||
|
|
||||||
# storybook
|
# storybook
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
715
pnpm-lock.yaml
715
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
1124
src-tauri/Cargo.lock
generated
1124
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||||
|
@ -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
1
src-tauri/src/pty/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod ws_server;
|
227
src-tauri/src/pty/ws_server.rs
Normal file
227
src-tauri/src/pty/ws_server.rs
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
39
src/lib/components/Terminal.svelte
Normal file
39
src/lib/components/Terminal.svelte
Normal 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
165
src/lib/terminals.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
@ -69,6 +69,22 @@
|
|||||||
>
|
>
|
||||||
⌘K
|
⌘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>
|
||||||
|
57
src/routes/projects/[projectId]/terminal/+page.svelte
Normal file
57
src/routes/projects/[projectId]/terminal/+page.svelte
Normal 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>
|
25
src/routes/projects/[projectId]/terminal/+page.ts
Normal file
25
src/routes/projects/[projectId]/terminal/+page.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user