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
|
||||
.git/gb-*
|
||||
src-tauri/data.txt
|
||||
|
||||
# storybook
|
||||
storybook-static
|
||||
|
||||
|
||||
|
@ -50,8 +50,15 @@
|
||||
"nanoid": "^4.0.1",
|
||||
"posthog-js": "^1.46.1",
|
||||
"svelte-french-toast": "^1.0.3",
|
||||
"svelte-resize-observer": "^2.0.0",
|
||||
"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": {
|
||||
"@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"
|
||||
similar = "2.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"
|
||||
|
||||
[features]
|
||||
|
@ -3,6 +3,7 @@ mod events;
|
||||
mod fs;
|
||||
mod git;
|
||||
mod projects;
|
||||
mod pty;
|
||||
mod repositories;
|
||||
mod search;
|
||||
mod sessions;
|
||||
@ -16,6 +17,7 @@ extern crate log;
|
||||
use anyhow::{Context, Result};
|
||||
use deltas::Delta;
|
||||
use git::activity;
|
||||
use pty::ws_server::pty_serve;
|
||||
use serde::{ser::SerializeMap, Serialize};
|
||||
use std::{collections::HashMap, ops::Range, path::Path, sync::Mutex};
|
||||
use storage::Storage;
|
||||
@ -724,6 +726,12 @@ fn init(app_handle: tauri::AppHandle) -> Result<()> {
|
||||
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);
|
||||
|
||||
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
|
||||
</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>
|
||||
|
||||
<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