Initial commit — Exbyte Depot (native Perforce client)

Tauri 2 + React 19 + TypeScript. GitHub Desktop-style two-stage flow
(local Commit -> Submit), async p4 backend, three.js asset viewer,
resizable persisted panels, History revert (p4 undo), reveal-in-Explorer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 13:44:18 +03:00
commit f33ab7435b
42 changed files with 9855 additions and 0 deletions

596
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,596 @@
// Exbyte Depot — Perforce client backend.
// Thin wrappers around the `p4` CLI that return JSON (`-Mj`) to the frontend.
use serde_json::Value;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use tauri::State;
#[derive(Default, Clone)]
struct Conn {
port: String,
user: String,
client: String,
}
#[derive(Default)]
struct AppState(Mutex<Conn>);
/// Build a `p4` command with the connection's global flags applied.
/// On Windows we set CREATE_NO_WINDOW so no console flashes on every call.
fn p4_cmd(conn: &Conn, tagged: bool) -> Command {
let mut c = Command::new("p4");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
if !conn.port.is_empty() {
c.args(["-p", &conn.port]);
}
if !conn.user.is_empty() {
c.args(["-u", &conn.user]);
}
if !conn.client.is_empty() {
c.args(["-c", &conn.client]);
}
if tagged {
c.arg("-ztag").arg("-Mj"); // tagged, JSON-marshalled fields (one object per line)
}
c
}
/// Run a tagged (JSON) p4 command and collect the objects it prints.
fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
let out = p4_cmd(conn, true)
.args(args)
.output()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let mut items = Vec::new();
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let v: Value = match serde_json::from_str(t) {
Ok(v) => v,
Err(_) => continue,
};
// real error: code=="error" or severity >= 3 (E_FAILED / E_FATAL)
let is_error = v.get("code").and_then(|c| c.as_str()) == Some("error")
|| v.get("severity").and_then(|s| s.as_i64()).map_or(false, |s| s >= 3);
if is_error {
let msg = v
.get("data")
.and_then(|d| d.as_str())
.unwrap_or("unknown p4 error")
.trim()
.to_string();
return Err(msg);
}
// informational message (e.g. "File(s) not opened."), not a data record — skip
let is_message = v.get("data").is_some()
&& (v.get("generic").is_some() || v.get("severity").is_some() || v.get("level").is_some());
if is_message {
continue;
}
items.push(v);
}
if items.is_empty() && !out.status.success() {
let e = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !e.is_empty() {
return Err(e);
}
}
Ok(items)
}
/// Trust the SSL fingerprint (first-time connect to an ssl: server).
fn trust(port: &str) {
let mut c = Command::new("p4");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
let _ = c.args(["-p", port, "trust", "-y", "-f"]).output();
}
/// List workspaces (clients) for a user — used to populate the connect screen.
#[tauri::command]
async fn p4_clients(port: String, user: String) -> Result<Vec<Value>, String> {
trust(&port);
let conn = Conn {
port,
user: user.clone(),
client: String::new(),
};
run_json(&conn, &["clients", "-u", &user])
}
/// Authenticate. Runs `p4 login` feeding the password on stdin, then stores
/// the connection and returns `p4 info`.
#[tauri::command]
async fn p4_login(
state: State<'_, AppState>,
port: String,
user: String,
password: String,
client: String,
) -> Result<Value, String> {
trust(&port);
let conn = Conn {
port: port.clone(),
user: user.clone(),
client: client.clone(),
};
let mut child = p4_cmd(&conn, false)
.arg("login")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
if let Some(si) = child.stdin.as_mut() {
si.write_all(format!("{password}\n").as_bytes())
.map_err(|e| e.to_string())?;
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let err = if err.trim().is_empty() {
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else {
err.trim().to_string()
};
return Err(if err.is_empty() {
"Ошибка входа (неверный логин или пароль)".into()
} else {
err
});
}
*state.0.lock().unwrap() = conn.clone();
let info = run_json(&conn, &["info"])?;
Ok(info.into_iter().next().unwrap_or(Value::Null))
}
/// Restore a saved session using the on-disk p4 ticket (no password).
/// Fails if the ticket has expired — the UI then falls back to the login form.
#[tauri::command]
async fn p4_restore(
state: State<'_, AppState>,
port: String,
user: String,
client: String,
) -> Result<Value, String> {
trust(&port);
let conn = Conn { port, user, client };
// `p4 login -s` reports ticket status; errors if not authenticated.
run_json(&conn, &["login", "-s"])?;
*state.0.lock().unwrap() = conn.clone();
let info = run_json(&conn, &["info"])?;
Ok(info.into_iter().next().unwrap_or(Value::Null))
}
fn current(state: &State<AppState>) -> Result<Conn, String> {
let c = state.0.lock().unwrap().clone();
if c.port.is_empty() {
return Err("Нет активного подключения".into());
}
Ok(c)
}
#[tauri::command]
async fn p4_info(state: State<'_, AppState>) -> Result<Value, String> {
let conn = current(&state)?;
let info = run_json(&conn, &["info"])?;
Ok(info.into_iter().next().unwrap_or(Value::Null))
}
/// Build a `path/...` spec from a scope depot folder (empty = whole workspace).
fn scope_spec(scope: &str) -> String {
if scope.is_empty() {
"//...".to_string()
} else {
format!("{}/...", scope.trim_end_matches(['/', '\\']))
}
}
/// Files currently open, optionally scoped to a depot folder.
#[tauri::command]
async fn p4_opened(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
if scope.is_empty() {
run_json(&conn, &["opened"])
} else {
run_json(&conn, &["opened", &scope_spec(&scope)])
}
}
/// List sub-directories of a depot path (for the working-folder picker).
#[tauri::command]
async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let spec = if path.is_empty() {
"//*".to_string()
} else {
format!("{}/*", path.trim_end_matches(['/', '\\']))
};
run_json(&conn, &["dirs", &spec])
}
/// Pending changelists for the current user that actually contain files.
/// Empty pending changelists (leftovers from a reverted/aborted commit) are
/// cleaned up so they never show a phantom "ready to Submit" or fail submit.
#[tauri::command]
async fn p4_changes(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let changes = run_json(
&conn,
&["changes", "-s", "pending", "-u", &conn.user, "-c", &conn.client],
)?;
// which pending changelists hold at least one opened file?
let opened = run_json(&conn, &["opened"]).unwrap_or_default();
let mut with_files = std::collections::HashSet::new();
for o in &opened {
if let Some(ch) = o.get("change").and_then(|c| c.as_str()) {
with_files.insert(ch.to_string());
}
}
let mut result = Vec::new();
for c in changes {
let num = c
.get("change")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if num.is_empty() {
continue;
}
if with_files.contains(&num) {
result.push(c);
} else {
// empty pending changelist — delete it (ignore failures, e.g. shelved files)
let _ = run_text(&conn, &["change", "-d", &num]);
}
}
Ok(result)
}
/// Submitted history for a depot path (History tab).
#[tauri::command]
async fn p4_history(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let spec = if path.is_empty() { "//...".to_string() } else { path };
run_json(&conn, &["changes", "-s", "submitted", "-m", "50", &spec])
}
#[tauri::command]
async fn p4_disconnect(state: State<'_, AppState>) -> Result<(), String> {
*state.0.lock().unwrap() = Conn::default();
Ok(())
}
/// Run a non-tagged p4 command (raw text out). Errors carry stderr.
fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
let out = p4_cmd(conn, false)
.args(args)
.output()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !out.status.success() && !stderr.is_empty() {
return Err(stderr);
}
Ok(if stdout.is_empty() { stderr } else { stdout })
}
/// Get Latest — sync to head, optionally scoped to a depot folder.
#[tauri::command]
async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
let msg = run_text(&conn, &["sync", &scope_spec(&scope)])?;
Ok(if msg.is_empty() { "Уже актуально — обновлять нечего.".into() } else { msg })
}
/// Unified diff of an opened file vs. the have revision.
#[tauri::command]
async fn p4_diff(state: State<'_, AppState>, file: String) -> Result<String, String> {
let conn = current(&state)?;
run_text(&conn, &["diff", "-du", &file])
}
/// Revert the given depot files (discard local edits).
#[tauri::command]
async fn p4_revert(state: State<'_, AppState>, files: Vec<String>) -> Result<String, String> {
let conn = current(&state)?;
let mut args: Vec<&str> = vec!["revert"];
for f in &files {
args.push(f.as_str());
}
run_text(&conn, &args)
}
/// Create an empty numbered changelist with a description; returns its number.
/// (`p4 submit` accepts only one file arg, so partial submits go through a
/// numbered changelist: create → reopen selected files into it → submit -c.)
fn create_changelist(conn: &Conn, desc: &str) -> Result<String, String> {
let body = desc.trim().replace('\n', "\n\t");
let spec = format!("Change:\tnew\nStatus:\tnew\nDescription:\n\t{body}\n");
let mut child = p4_cmd(conn, false)
.args(["change", "-i"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
if let Some(si) = child.stdin.as_mut() {
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
// stdout: "Change 23 created."
let s = String::from_utf8_lossy(&out.stdout);
s.split_whitespace()
.find(|w| w.chars().all(|c| c.is_ascii_digit()) && !w.is_empty())
.map(|n| n.to_string())
.ok_or_else(|| format!("Не удалось создать changelist: {}", s.trim()))
}
/// "Commit" (local): create a pending numbered changelist with a description and
/// move the selected files into it. Nothing goes to the server yet — this is
/// revertable. Returns the changelist number.
#[tauri::command]
async fn p4_commit(
state: State<'_, AppState>,
description: String,
files: Vec<String>,
) -> Result<String, String> {
let conn = current(&state)?;
if files.is_empty() {
return Err("Нет файлов для коммита".into());
}
let cl = create_changelist(&conn, &description)?;
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files {
reopen.push(f.as_str());
}
run_text(&conn, &reopen)?;
Ok(cl)
}
/// Submit a numbered pending changelist to the server (the "push").
#[tauri::command]
async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
let conn = current(&state)?;
run_text(&conn, &["submit", "-c", &change])
}
/// Submit the selected files with a description.
#[tauri::command]
async fn p4_submit(
state: State<'_, AppState>,
description: String,
files: Vec<String>,
) -> Result<String, String> {
let conn = current(&state)?;
if files.is_empty() {
return Err("Не выбрано ни одного файла для submit".into());
}
let total = run_json(&conn, &["opened"])?.len();
// all opened files selected → submit the whole default changelist in one go
if files.len() >= total {
return run_text(&conn, &["submit", "-d", &description]);
}
// partial submit → dedicated numbered changelist
let cl = create_changelist(&conn, &description)?;
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files {
reopen.push(f.as_str());
}
run_text(&conn, &reopen)?;
run_text(&conn, &["submit", "-c", &cl])
}
/// Run a p4 command over a list of file paths, returning the opened records.
fn files_cmd(conn: &Conn, cmd: &str, files: &[String]) -> Result<Vec<Value>, String> {
let mut args: Vec<&str> = vec![cmd];
for f in files {
args.push(f.as_str());
}
run_json(conn, &args)
}
/// Mark files for add.
#[tauri::command]
async fn p4_add(state: State<'_, AppState>, files: Vec<String>) -> Result<Vec<Value>, String> {
files_cmd(&current(&state)?, "add", &files)
}
/// Open files for edit (check out).
#[tauri::command]
async fn p4_edit(state: State<'_, AppState>, files: Vec<String>) -> Result<Vec<Value>, String> {
files_cmd(&current(&state)?, "edit", &files)
}
/// Mark files for delete.
#[tauri::command]
async fn p4_delete(state: State<'_, AppState>, files: Vec<String>) -> Result<Vec<Value>, String> {
files_cmd(&current(&state)?, "delete", &files)
}
/// Reconcile a path — auto-detect adds/edits/deletes vs. the depot.
/// This is what a drag-drop of files/folders uses.
#[tauri::command]
async fn p4_reconcile(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
// depot spec (//...) → recurse; local folder → recurse; local file → as-is
let spec = if path.starts_with("//") {
if path.ends_with("...") { path.clone() } else { format!("{}/...", path.trim_end_matches('/')) }
} else if std::path::Path::new(&path).is_dir() {
format!("{}/...", path.trim_end_matches(['/', '\\']))
} else {
path.clone()
};
run_json(&conn, &["reconcile", "-e", "-a", "-d", &spec])
}
/// Scan the working scope for new / changed / deleted files and open them
/// (reconcile). This is what surfaces assets created outside the client —
/// e.g. new .uasset files that Unreal wrote straight to disk but that were
/// never `p4 add`-ed, so they don't yet show up in `p4 opened`.
#[tauri::command]
async fn p4_scan(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
run_json(&conn, &["reconcile", "-e", "-a", "-d", &scope_spec(&scope)])
}
/// Undo (roll back) an already-submitted changelist. Perforce (2019.1+) does this
/// with `p4 undo`: it opens the reverting revisions, which we drop into a fresh
/// numbered changelist and submit — so the rollback is itself a new change, exactly
/// like GitHub Desktop's "Revert this commit". Returns the submit output.
#[tauri::command]
async fn p4_undo_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
let conn = current(&state)?;
let cl = create_changelist(&conn, &format!("Revert change {change}"))?;
let spec = format!("//...@={change}");
// -c keeps the undone files isolated in our changelist (won't touch other open work)
run_text(&conn, &["undo", "-c", &cl, &spec])?;
run_text(&conn, &["submit", "-c", &cl])
}
/// Reveal a depot folder/file (or a raw local path) in the OS file explorer.
/// Depot paths are resolved to the local working copy via `p4 where`.
#[tauri::command]
async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<(), String> {
let local = if target.starts_with("//") {
let conn = current(&state)?;
let spec = format!("{}/...", target.trim_end_matches(['/', '.']));
let p = run_json(&conn, &["where", &spec])?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|x| x.as_str()).map(String::from))
.ok_or_else(|| "Не удалось определить локальный путь".to_string())?;
// strip the trailing "..." wildcard and separators → the folder itself
p.trim_end_matches(['.', '/', '\\']).to_string()
} else {
target
};
let path = std::path::Path::new(&local);
if !path.exists() {
return Err(format!("Путь ещё не синхронизирован на диск: {local}"));
}
let mut c = Command::new("explorer");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
// reveal-and-select for files, open-folder for directories
if path.is_file() {
c.arg(format!("/select,{local}"));
} else {
c.arg(&local);
}
c.spawn().map_err(|e| format!("Не удалось открыть проводник: {e}"))?;
Ok(())
}
/// Files and metadata of a submitted changelist (History detail).
#[tauri::command]
async fn p4_describe(state: State<'_, AppState>, change: String) -> Result<Value, String> {
let conn = current(&state)?;
let v = run_json(&conn, &["describe", "-s", &change])?;
Ok(v.into_iter().next().unwrap_or(Value::Null))
}
/// Read raw bytes of a local file — used by the 3D model / texture viewer.
#[tauri::command]
async fn read_file(path: String) -> Result<tauri::ipc::Response, String> {
std::fs::read(&path)
.map(tauri::ipc::Response::new)
.map_err(|e| format!("Не удалось прочитать файл: {e}"))
}
/// Read the local working-copy bytes of a depot file (resolves via `p4 where`).
#[tauri::command]
async fn read_depot(state: State<'_, AppState>, depot: String) -> Result<tauri::ipc::Response, String> {
let conn = current(&state)?;
let local = run_json(&conn, &["where", &depot])?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
.ok_or_else(|| "Не удалось определить путь файла".to_string())?;
std::fs::read(&local)
.map(tauri::ipc::Response::new)
.map_err(|e| format!("Не удалось прочитать файл: {e}"))
}
/// Switch the active workspace (client) without re-authenticating.
#[tauri::command]
async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result<Value, String> {
let conn = {
let mut c = state.0.lock().unwrap();
if c.port.is_empty() {
return Err("Нет активного подключения".into());
}
c.client = client;
c.clone()
};
let info = run_json(&conn, &["info"])?;
Ok(info.into_iter().next().unwrap_or(Value::Null))
}
/// List depots on the server (for the Depot-path picker).
#[tauri::command]
async fn p4_depots(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
run_json(&conn, &["depots"])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(AppState::default())
.invoke_handler(tauri::generate_handler![
p4_clients,
p4_login,
p4_restore,
p4_info,
p4_opened,
p4_changes,
p4_history,
p4_disconnect,
p4_sync,
p4_diff,
p4_revert,
p4_submit,
p4_commit,
p4_submit_change,
p4_add,
p4_edit,
p4_delete,
p4_reconcile,
p4_scan,
p4_undo_change,
open_in_explorer,
p4_describe,
read_file,
read_depot,
p4_switch_client,
p4_depots,
p4_dirs,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View 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() {
exbyte_depot_lib::run()
}