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>
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
|
||||
5031
src-tauri/Cargo.lock
generated
Normal file
26
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
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 = "exbyte_depot_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
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.0 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 |
596
src-tauri/src/lib.rs
Normal 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(¤t(&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(¤t(&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(¤t(&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
@ -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()
|
||||
}
|
||||
40
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "exbyte-depot",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Exbyte Depot",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||