init
7
src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5307
src-tauri/Cargo.lock
generated
Normal file
26
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "githikari"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "githikari_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = { version = "2.7.1", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
portable-pty = "0.9.0"
|
||||
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"opener:default",
|
||||
"opener:allow-open-path",
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
913
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,913 @@
|
|||
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
struct TerminalSession {
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
killer: Box<dyn ChildKiller + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalState {
|
||||
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TerminalOutput {
|
||||
id: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TerminalExit {
|
||||
id: String,
|
||||
code: u32,
|
||||
signal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GitStatusEntry {
|
||||
path: String,
|
||||
status: String,
|
||||
index_status: String,
|
||||
worktree_status: String,
|
||||
old_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GitStatusSummary {
|
||||
staged: usize,
|
||||
unstaged: usize,
|
||||
untracked: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GitRecentCommit {
|
||||
hash: String,
|
||||
subject: String,
|
||||
author: String,
|
||||
relative_time: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GitRepositoryStatus {
|
||||
root_path: String,
|
||||
branch: String,
|
||||
head: Option<String>,
|
||||
ahead: usize,
|
||||
behind: usize,
|
||||
entries: Vec<GitStatusEntry>,
|
||||
summary: GitStatusSummary,
|
||||
recent_commits: Vec<GitRecentCommit>,
|
||||
}
|
||||
|
||||
const PRIMARY_DEVICE_ATTRIBUTES_RESPONSE: &[u8] = b"\x1b[?1;2c";
|
||||
const TERMINAL_QUERIES_TO_ANSWER: [&[u8]; 2] = [b"\x1b[c", b"\x1b[0c"];
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalOutputFilter {
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TerminalOutputFilter {
|
||||
fn push(&mut self, input: &[u8]) -> (Vec<u8>, usize) {
|
||||
let mut source = Vec::with_capacity(self.pending.len() + input.len());
|
||||
source.extend_from_slice(&self.pending);
|
||||
source.extend_from_slice(input);
|
||||
self.pending.clear();
|
||||
|
||||
let mut output = Vec::with_capacity(source.len());
|
||||
let mut response_count = 0;
|
||||
let mut index = 0;
|
||||
|
||||
while index < source.len() {
|
||||
if let Some(query) = TERMINAL_QUERIES_TO_ANSWER
|
||||
.iter()
|
||||
.find(|query| source[index..].starts_with(query))
|
||||
{
|
||||
response_count += 1;
|
||||
index += query.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
if TERMINAL_QUERIES_TO_ANSWER
|
||||
.iter()
|
||||
.any(|query| query.starts_with(&source[index..]))
|
||||
{
|
||||
self.pending.extend_from_slice(&source[index..]);
|
||||
break;
|
||||
}
|
||||
|
||||
output.push(source[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
(output, response_count)
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_size(cols: u16, rows: u16) -> PtySize {
|
||||
PtySize {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|shell| !shell.trim().is_empty())
|
||||
.unwrap_or_else(|| "/bin/sh".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_terminal(
|
||||
app: AppHandle,
|
||||
state: State<'_, TerminalState>,
|
||||
id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
cwd: Option<String>,
|
||||
shell: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
kill_terminal(state.clone(), id.clone()).ok();
|
||||
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(terminal_size(cols, rows))
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let mut command = CommandBuilder::new(shell.unwrap_or_else(default_shell));
|
||||
command.env("TERM", "xterm-256color");
|
||||
command.env("COLORTERM", "truecolor");
|
||||
|
||||
if let Some(cwd) = cwd.filter(|cwd| !cwd.trim().is_empty()) {
|
||||
command.cwd(cwd);
|
||||
}
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let writer = Arc::new(Mutex::new(
|
||||
pair.master
|
||||
.take_writer()
|
||||
.map_err(|error| error.to_string())?,
|
||||
));
|
||||
let killer = child.clone_killer();
|
||||
let output_writer = writer.clone();
|
||||
|
||||
{
|
||||
let mut sessions = state.sessions.lock().map_err(|error| error.to_string())?;
|
||||
sessions.insert(
|
||||
id.clone(),
|
||||
TerminalSession {
|
||||
master: pair.master,
|
||||
writer,
|
||||
killer,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let output_app = app.clone();
|
||||
let output_id = id.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let mut output_filter = TerminalOutputFilter::default();
|
||||
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => {
|
||||
let (output, response_count) = output_filter.push(&buffer[..size]);
|
||||
|
||||
if response_count > 0 {
|
||||
if let Ok(mut writer) = output_writer.lock() {
|
||||
for _ in 0..response_count {
|
||||
let _ = writer.write_all(PRIMARY_DEVICE_ATTRIBUTES_RESPONSE);
|
||||
}
|
||||
|
||||
let _ = writer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
let data = String::from_utf8_lossy(&output).into_owned();
|
||||
let _ = output_app.emit(
|
||||
"terminal-output",
|
||||
TerminalOutput {
|
||||
id: output_id.clone(),
|
||||
data,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let exit_app = app.clone();
|
||||
let exit_id = id.clone();
|
||||
let sessions = state.sessions.clone();
|
||||
thread::spawn(move || {
|
||||
if let Ok(status) = child.wait() {
|
||||
if let Ok(mut sessions) = sessions.lock() {
|
||||
sessions.remove(&exit_id);
|
||||
}
|
||||
|
||||
let _ = exit_app.emit(
|
||||
"terminal-exit",
|
||||
TerminalExit {
|
||||
id: exit_id,
|
||||
code: status.exit_code(),
|
||||
signal: status.signal().map(ToString::to_string),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_terminal(state: State<'_, TerminalState>, id: String, data: String) -> Result<(), String> {
|
||||
let writer = {
|
||||
let sessions = state.sessions.lock().map_err(|error| error.to_string())?;
|
||||
sessions
|
||||
.get(&id)
|
||||
.map(|session| session.writer.clone())
|
||||
.ok_or_else(|| format!("terminal session not found: {id}"))?
|
||||
};
|
||||
|
||||
let mut writer = writer.lock().map_err(|error| error.to_string())?;
|
||||
|
||||
writer
|
||||
.write_all(data.as_bytes())
|
||||
.and_then(|_| writer.flush())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resize_terminal(
|
||||
state: State<'_, TerminalState>,
|
||||
id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), String> {
|
||||
let sessions = state.sessions.lock().map_err(|error| error.to_string())?;
|
||||
let session = sessions
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("terminal session not found: {id}"))?;
|
||||
|
||||
session
|
||||
.master
|
||||
.resize(terminal_size(cols, rows))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_terminal(state: State<'_, TerminalState>, id: String) -> Result<(), String> {
|
||||
let session = {
|
||||
let mut sessions = state.sessions.lock().map_err(|error| error.to_string())?;
|
||||
sessions.remove(&id)
|
||||
};
|
||||
|
||||
if let Some(mut session) = session {
|
||||
session.killer.kill().map_err(|error| error.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(repo_path: &str, args: &[&str]) -> Result<Vec<u8>, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run git: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("git exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git_allowing_diff(repo_path: &str, args: &[&str]) -> Result<Vec<u8>, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run git: {error}"))?;
|
||||
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("git exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
fn git_root(repo_path: &str) -> Result<String, String> {
|
||||
let output = run_git(repo_path, &["rev-parse", "--show-toplevel"])?;
|
||||
let root_path = String::from_utf8(output)
|
||||
.map_err(|error| error.to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if root_path.is_empty() {
|
||||
return Err("selected folder is not a git repository".to_string());
|
||||
}
|
||||
|
||||
Ok(root_path)
|
||||
}
|
||||
|
||||
fn resolve_repo_file_path(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
|
||||
let trimmed_path = file_path.trim();
|
||||
|
||||
if trimmed_path.is_empty() {
|
||||
return Ok(PathBuf::from(root_path));
|
||||
}
|
||||
|
||||
if Path::new(trimmed_path).is_absolute() || trimmed_path.contains("..") {
|
||||
return Err("file path must stay inside the repository".to_string());
|
||||
}
|
||||
|
||||
Ok(Path::new(root_path).join(trimmed_path))
|
||||
}
|
||||
|
||||
fn git_branch(root_path: &str) -> Result<String, String> {
|
||||
let output = run_git(root_path, &["branch", "--show-current"])?;
|
||||
let branch = String::from_utf8(output)
|
||||
.map_err(|error| error.to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if !branch.is_empty() {
|
||||
return Ok(branch);
|
||||
}
|
||||
|
||||
let head = git_head(root_path).unwrap_or_else(|| "unborn".to_string());
|
||||
|
||||
Ok(format!("detached @ {head}"))
|
||||
}
|
||||
|
||||
fn git_head(root_path: &str) -> Option<String> {
|
||||
let output = run_git(root_path, &["rev-parse", "--short", "HEAD"]).ok()?;
|
||||
let head = String::from_utf8(output).ok()?.trim().to_string();
|
||||
|
||||
if head.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(head)
|
||||
}
|
||||
}
|
||||
|
||||
fn git_has_head(root_path: &str) -> bool {
|
||||
run_git(root_path, &["rev-parse", "--verify", "HEAD"]).is_ok()
|
||||
}
|
||||
|
||||
fn git_ahead_behind(root_path: &str) -> (usize, usize) {
|
||||
let output = match run_git(
|
||||
root_path,
|
||||
&["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
|
||||
) {
|
||||
Ok(output) => output,
|
||||
Err(_) => return (0, 0),
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output);
|
||||
let mut parts = text.split_whitespace();
|
||||
let behind = parts
|
||||
.next()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let ahead = parts
|
||||
.next()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
fn git_recent_commits(root_path: &str) -> Vec<GitRecentCommit> {
|
||||
let output = match run_git(
|
||||
root_path,
|
||||
&[
|
||||
"log",
|
||||
"-8",
|
||||
"--date=relative",
|
||||
"--pretty=format:%h%x1f%s%x1f%an%x1f%cr%x1e",
|
||||
],
|
||||
) {
|
||||
Ok(output) => output,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
String::from_utf8_lossy(&output)
|
||||
.split('\x1e')
|
||||
.filter_map(|record| {
|
||||
let mut fields = record.trim_matches('\n').split('\x1f');
|
||||
Some(GitRecentCommit {
|
||||
hash: fields.next()?.to_string(),
|
||||
subject: fields.next()?.to_string(),
|
||||
author: fields.next()?.to_string(),
|
||||
relative_time: fields.next()?.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classify_git_status(index_status: char, worktree_status: char) -> String {
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
return "untracked".to_string();
|
||||
}
|
||||
|
||||
if index_status == '!' && worktree_status == '!' {
|
||||
return "ignored".to_string();
|
||||
}
|
||||
|
||||
if index_status == 'R' || worktree_status == 'R' {
|
||||
return "renamed".to_string();
|
||||
}
|
||||
|
||||
if index_status == 'D' || worktree_status == 'D' {
|
||||
return "deleted".to_string();
|
||||
}
|
||||
|
||||
if index_status == 'A' || worktree_status == 'A' {
|
||||
return "added".to_string();
|
||||
}
|
||||
|
||||
"modified".to_string()
|
||||
}
|
||||
|
||||
fn parse_porcelain_status(output: &[u8]) -> Vec<GitStatusEntry> {
|
||||
let records: Vec<&[u8]> = output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|record| !record.is_empty())
|
||||
.collect();
|
||||
let mut entries = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < records.len() {
|
||||
let record = String::from_utf8_lossy(records[index]);
|
||||
let bytes = record.as_bytes();
|
||||
|
||||
if bytes.len() < 4 {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let index_status = bytes[0] as char;
|
||||
let worktree_status = bytes[1] as char;
|
||||
let mut path = record[3..].to_string();
|
||||
let mut old_path = None;
|
||||
|
||||
if (index_status == 'R' || index_status == 'C') && index + 1 < records.len() {
|
||||
old_path = Some(path);
|
||||
index += 1;
|
||||
path = String::from_utf8_lossy(records[index]).to_string();
|
||||
}
|
||||
|
||||
entries.push(GitStatusEntry {
|
||||
path,
|
||||
status: classify_git_status(index_status, worktree_status),
|
||||
index_status: index_status.to_string(),
|
||||
worktree_status: worktree_status.to_string(),
|
||||
old_path,
|
||||
});
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn summarize_status(entries: &[GitStatusEntry]) -> GitStatusSummary {
|
||||
GitStatusSummary {
|
||||
staged: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.index_status != " " && entry.index_status != "?")
|
||||
.count(),
|
||||
unstaged: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.worktree_status != " " && entry.worktree_status != "?")
|
||||
.count(),
|
||||
untracked: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.index_status == "?" && entry.worktree_status == "?")
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_repository_status(repo_path: String) -> Result<GitRepositoryStatus, String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let status_output = run_git(
|
||||
&root_path,
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
"--renames",
|
||||
],
|
||||
)?;
|
||||
let entries = parse_porcelain_status(&status_output);
|
||||
let (ahead, behind) = git_ahead_behind(&root_path);
|
||||
|
||||
Ok(GitRepositoryStatus {
|
||||
branch: git_branch(&root_path)?,
|
||||
head: git_head(&root_path),
|
||||
ahead,
|
||||
behind,
|
||||
summary: summarize_status(&entries),
|
||||
recent_commits: git_recent_commits(&root_path),
|
||||
root_path,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_paths(paths: Option<Vec<String>>) -> Vec<String> {
|
||||
paths
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| path.trim().to_string())
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_git_with_paths(
|
||||
root_path: &str,
|
||||
args_before_pathspec: &[&str],
|
||||
paths: &[String],
|
||||
) -> Result<(), String> {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(root_path).args(args_before_pathspec);
|
||||
|
||||
if !paths.is_empty() {
|
||||
command.arg("--");
|
||||
|
||||
for path in paths {
|
||||
command.arg(path);
|
||||
}
|
||||
}
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run git: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("git exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_stage_paths(repo_path: String, paths: Option<Vec<String>>) -> Result<(), String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let paths = normalized_paths(paths);
|
||||
|
||||
if paths.is_empty() {
|
||||
return run_git_with_paths(&root_path, &["add", "-A"], &paths);
|
||||
}
|
||||
|
||||
run_git_with_paths(&root_path, &["add"], &paths)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_unstage_paths(repo_path: String, paths: Option<Vec<String>>) -> Result<(), String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let paths = normalized_paths(paths);
|
||||
|
||||
run_git_with_paths(&root_path, &["reset", "HEAD"], &paths)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_discard_paths(repo_path: String, paths: Vec<String>) -> Result<(), String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let paths = normalized_paths(Some(paths));
|
||||
|
||||
if paths.is_empty() {
|
||||
return Err("no paths selected".to_string());
|
||||
}
|
||||
|
||||
run_git_with_paths(&root_path, &["restore", "--staged", "--worktree"], &paths).ok();
|
||||
run_git_with_paths(&root_path, &["clean", "-fd"], &paths)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit(repo_path: String, message: String) -> Result<(), String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let message = message.trim();
|
||||
|
||||
if message.is_empty() {
|
||||
return Err("commit message is empty".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&root_path)
|
||||
.arg("commit")
|
||||
.arg("-m")
|
||||
.arg(message)
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run git: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("git exited with status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_open_path(repo_path: String, file_path: String, reveal: bool) -> Result<(), String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let target_path = resolve_repo_file_path(&root_path, &file_path)?;
|
||||
let open_path = if reveal {
|
||||
if target_path.is_dir() {
|
||||
target_path
|
||||
} else {
|
||||
target_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from(root_path))
|
||||
}
|
||||
} else {
|
||||
target_path
|
||||
};
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(open_path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("failed to open path: {error}"))
|
||||
}
|
||||
|
||||
fn list_untracked_paths(root_path: &str, pathspec: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let output = match pathspec {
|
||||
Some(pathspec) => run_git(
|
||||
root_path,
|
||||
&[
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
"--",
|
||||
pathspec,
|
||||
],
|
||||
)?,
|
||||
None => run_git(
|
||||
root_path,
|
||||
&["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|record| !record.is_empty())
|
||||
.map(|record| String::from_utf8_lossy(record).to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn append_untracked_diff(root_path: &str, path: &str, output: &mut Vec<u8>) -> Result<(), String> {
|
||||
let absolute_path = Path::new(root_path).join(path);
|
||||
let absolute_path = absolute_path
|
||||
.to_str()
|
||||
.ok_or_else(|| "file path is not valid UTF-8".to_string())?;
|
||||
let mut diff = run_git_allowing_diff(
|
||||
root_path,
|
||||
&[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--",
|
||||
"/dev/null",
|
||||
absolute_path,
|
||||
],
|
||||
)?;
|
||||
|
||||
if !output.is_empty() && !output.ends_with(b"\n") {
|
||||
output.push(b'\n');
|
||||
}
|
||||
|
||||
output.append(&mut diff);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_bytes(output: &mut Vec<u8>, mut next: Vec<u8>) {
|
||||
if next.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !output.is_empty() && !output.ends_with(b"\n") {
|
||||
output.push(b'\n');
|
||||
}
|
||||
|
||||
output.append(&mut next);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_diff_file(repo_path: String, file_path: Option<String>) -> Result<String, String> {
|
||||
let root_path = git_root(&repo_path)?;
|
||||
let normalized_path = file_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty());
|
||||
let untracked_paths = list_untracked_paths(&root_path, normalized_path)?;
|
||||
let has_head = git_has_head(&root_path);
|
||||
|
||||
let mut diff_output = Vec::new();
|
||||
|
||||
match (has_head, normalized_path) {
|
||||
(_, Some(path)) if untracked_paths.iter().any(|untracked| untracked == path) => {}
|
||||
(true, Some(path)) => append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(
|
||||
&root_path,
|
||||
&[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--find-renames",
|
||||
"HEAD",
|
||||
"--",
|
||||
path,
|
||||
],
|
||||
)?,
|
||||
),
|
||||
(true, None) => append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(
|
||||
&root_path,
|
||||
&[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--find-renames",
|
||||
"HEAD",
|
||||
],
|
||||
)?,
|
||||
),
|
||||
(false, Some(path)) => {
|
||||
append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(
|
||||
&root_path,
|
||||
&[
|
||||
"diff",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--",
|
||||
path,
|
||||
],
|
||||
)?,
|
||||
);
|
||||
append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(
|
||||
&root_path,
|
||||
&["diff", "--no-ext-diff", "--no-color", "--", path],
|
||||
)?,
|
||||
);
|
||||
}
|
||||
(false, None) => {
|
||||
append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(
|
||||
&root_path,
|
||||
&["diff", "--cached", "--no-ext-diff", "--no-color"],
|
||||
)?,
|
||||
);
|
||||
append_bytes(
|
||||
&mut diff_output,
|
||||
run_git_allowing_diff(&root_path, &["diff", "--no-ext-diff", "--no-color"])?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for untracked_path in untracked_paths {
|
||||
append_untracked_diff(&root_path, &untracked_path, &mut diff_output)?;
|
||||
}
|
||||
|
||||
String::from_utf8(diff_output).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(TerminalState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
spawn_terminal,
|
||||
write_terminal,
|
||||
resize_terminal,
|
||||
kill_terminal,
|
||||
git_repository_status,
|
||||
git_diff_file,
|
||||
git_stage_paths,
|
||||
git_unstage_paths,
|
||||
git_discard_paths,
|
||||
git_commit,
|
||||
git_open_path
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TerminalOutputFilter;
|
||||
|
||||
#[test]
|
||||
fn terminal_output_filter_answers_primary_device_attributes() {
|
||||
let mut filter = TerminalOutputFilter::default();
|
||||
|
||||
let (output, response_count) = filter.push(b"hello\x1b[cworld");
|
||||
|
||||
assert_eq!(output, b"helloworld");
|
||||
assert_eq!(response_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_output_filter_handles_split_primary_device_attributes() {
|
||||
let mut filter = TerminalOutputFilter::default();
|
||||
|
||||
let (first_output, first_response_count) = filter.push(b"hello\x1b[");
|
||||
let (second_output, second_response_count) = filter.push(b"cworld");
|
||||
|
||||
assert_eq!(first_output, b"hello");
|
||||
assert_eq!(first_response_count, 0);
|
||||
assert_eq!(second_output, b"world");
|
||||
assert_eq!(second_response_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_output_filter_preserves_non_query_escape_sequences() {
|
||||
let mut filter = TerminalOutputFilter::default();
|
||||
|
||||
let (first_output, first_response_count) = filter.push(b"\x1b[");
|
||||
let (second_output, second_response_count) = filter.push(b"31mred");
|
||||
|
||||
assert!(first_output.is_empty());
|
||||
assert_eq!(first_response_count, 0);
|
||||
assert_eq!(second_output, b"\x1b[31mred");
|
||||
assert_eq!(second_response_count, 0);
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
githikari_lib::run()
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "githikari",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ng.imnya.githikari",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "wow",
|
||||
"width": 1600,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||