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>
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
7
README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
14
index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2191
package-lock.json
generated
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/three": "^0.185.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
6
public/tauri.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
374
src/App.css
Normal file
@ -0,0 +1,374 @@
|
||||
:root{
|
||||
--bg:#08080c;--panel:#12121b;--panel-2:#161621;--panel-3:#1c1c28;
|
||||
--border:#242433;--border-soft:#1d1d29;
|
||||
--txt:#ecedf4;--muted:#8a8a9c;--faint:#5a5a6a;
|
||||
--accent:#7c6ef6;--accent-2:#9a8bf9;--accent-deep:#5b49e6;
|
||||
--add:#3ecf8e;--edit:#e8b04b;--del:#f2637e;
|
||||
--win-a:#10101a;--win-b:#0c0c13;--elevated:#14141e;--input-bg:#0c0c13;
|
||||
--stage-bg:#0b0b12;--grid:rgba(255,255,255,.025);
|
||||
--bar:rgba(255,255,255,.015);--shadow:0 40px 90px -30px rgba(0,0,0,.7);
|
||||
--sunk:rgba(0,0,0,.15);--chip:rgba(0,0,0,.4);--hover:rgba(255,255,255,.05);
|
||||
--diff-add-c:#a8ecca;--diff-del-c:#f3a9b8;
|
||||
--font:"Segoe UI Variable Text","Segoe UI",-apple-system,BlinkMacSystemFont,system-ui,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||
}
|
||||
body.light{
|
||||
--bg:#eef0f5;--panel:#ffffff;--panel-2:#f4f5f9;--panel-3:#e9eaf1;
|
||||
--border:#e0e1ea;--border-soft:#ececf1;
|
||||
--txt:#1b1b26;--muted:#63636f;--faint:#9a9aa8;
|
||||
--accent:#6d5cf0;--accent-2:#7c6ef6;--accent-deep:#5b49e6;
|
||||
--add:#1fa971;--edit:#c98a1e;--del:#e0426a;
|
||||
--win-a:#ffffff;--win-b:#f6f7fb;--elevated:#ffffff;--input-bg:#f4f5f9;
|
||||
--stage-bg:#e7e8ee;--grid:rgba(0,0,0,.045);
|
||||
--bar:rgba(0,0,0,.015);--shadow:0 40px 90px -30px rgba(60,60,90,.35);
|
||||
--sunk:transparent;--chip:rgba(255,255,255,.72);--hover:rgba(0,0,0,.045);
|
||||
--diff-add-c:#137a4e;--diff-del-c:#c02b52;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body,#root{height:100%}
|
||||
body{
|
||||
font-family:var(--font);color:var(--txt);background:var(--bg);
|
||||
-webkit-font-smoothing:antialiased;letter-spacing:.1px;overflow:hidden;
|
||||
}
|
||||
button{font-family:var(--font)}
|
||||
|
||||
.win{height:100vh;width:100%;display:flex;flex-direction:column;
|
||||
background:linear-gradient(180deg,var(--win-a),var(--win-b));overflow:hidden}
|
||||
|
||||
/* titlebar + inline menu */
|
||||
.titlebar{height:48px;flex:0 0 48px;display:flex;align-items:stretch;gap:6px;padding:0 0 0 14px;
|
||||
background:var(--bar);border-bottom:1px solid var(--border-soft);position:relative;z-index:40}
|
||||
|
||||
/* frameless window controls (Windows style) */
|
||||
.wctrls{display:flex;align-self:stretch;margin-left:10px}
|
||||
.wbtn{width:46px;border:none;background:none;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s}
|
||||
.wbtn:hover{background:var(--hover);color:var(--txt)}
|
||||
.wbtn.close:hover{background:#e81123;color:#fff}
|
||||
.wbtn svg{width:11px;height:11px}
|
||||
.brand{display:flex;align-items:center;gap:10px;padding-right:10px;margin-right:4px;border-right:1px solid var(--border-soft)}
|
||||
.logo{width:22px;height:22px;border-radius:7px;background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));
|
||||
box-shadow:0 0 14px rgba(124,110,246,.55);display:grid;place-items:center;flex:0 0 auto}
|
||||
.logo svg{width:13px;height:13px}
|
||||
.app-name{font-size:13px;font-weight:600;white-space:nowrap}.app-name span{color:var(--faint);font-weight:500}
|
||||
.conn-pill{align-self:center;margin-left:auto;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted);
|
||||
background:rgba(62,207,142,.08);border:1px solid rgba(62,207,142,.2);padding:4px 10px;border-radius:20px;white-space:nowrap}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--add);box-shadow:0 0 8px var(--add)}
|
||||
.theme-btn{align-self:center;margin-left:12px;width:30px;height:30px;border-radius:9px;border:1px solid var(--border);
|
||||
background:var(--panel);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:.15s}
|
||||
.theme-btn:hover{color:var(--accent-2);border-color:var(--accent)}.theme-btn svg{width:16px;height:16px}
|
||||
|
||||
.menubar{display:flex;align-items:center;gap:2px;padding:7px 0}
|
||||
.menu{position:relative;display:flex;align-items:center;padding:7px 11px;font-size:12.5px;color:var(--muted);
|
||||
cursor:pointer;user-select:none;border-radius:9px;transition:background .15s,color .15s}
|
||||
.menu:hover,.menu.open{background:rgba(124,110,246,.12);color:var(--txt)}
|
||||
.dropdown{position:absolute;top:100%;left:0;min-width:210px;background:var(--elevated);border:1px solid var(--border);
|
||||
border-radius:10px;padding:6px;box-shadow:0 24px 60px -12px rgba(0,0,0,.45);display:none;flex-direction:column;gap:1px}
|
||||
.menu.open .dropdown{display:flex}
|
||||
.di{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer}
|
||||
.di:hover{background:rgba(124,110,246,.14)}
|
||||
.di .kb{margin-left:auto;font-size:10.5px;color:var(--faint);font-family:var(--mono)}
|
||||
.di.ext{color:var(--accent-2)}
|
||||
.sep{height:1px;background:var(--border-soft);margin:4px 6px}
|
||||
|
||||
/* toolbar — first divider tracks --left-w so it lines up with the left/right panel split */
|
||||
.toolbar{display:grid;grid-template-columns:var(--left-w,380px) 1fr;height:60px;flex:0 0 60px;
|
||||
border-bottom:1px solid var(--border);background:var(--bar)}
|
||||
.tzone{display:flex;align-items:center;gap:12px;padding:0 18px;cursor:pointer;position:relative;
|
||||
border-right:1px solid var(--border-soft);transition:background .15s}
|
||||
.toolbar > .tzone{border-right:1px solid var(--border)} /* the --left-w divider — matches .left */
|
||||
.tgroup{display:flex;min-width:0;position:relative}
|
||||
.tgroup .tzone{flex:1;min-width:0}
|
||||
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
|
||||
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
|
||||
|
||||
/* draggable panel dividers */
|
||||
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8;transition:background .12s}
|
||||
.vhandle:hover{background:rgba(124,110,246,.3)}
|
||||
.body-resizing-x .vhandle,body.resizing .vhandle{background:rgba(124,110,246,.45)}
|
||||
.main{position:relative}
|
||||
.main > .vhandle.mainh{left:var(--left-w,380px);transform:translateX(-4px)}
|
||||
.tgroup > .vhandle.grouph{right:var(--right-zone-w,220px);transform:translateX(4px)}
|
||||
body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.tzone .ic{color:var(--muted);flex:0 0 auto;display:grid;place-items:center}.tzone .ic svg{width:18px;height:18px}
|
||||
.tzmeta{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.tzone .lbl{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.7px}
|
||||
.tzone .val{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tzone .chev{margin-left:auto;color:var(--faint);flex:0 0 auto}.tzone .chev svg{width:14px;height:14px}
|
||||
.sync .val{color:var(--txt)}.sync .sub{font-size:11px;color:var(--muted)}.sync .ic{color:var(--accent-2)}
|
||||
.tzone.busy .ic{animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* main */
|
||||
.main{flex:1;display:grid;grid-template-columns:var(--left-w,380px) 1fr;min-height:0}
|
||||
.left{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0}
|
||||
.right{display:flex;flex-direction:column;min-height:0}
|
||||
|
||||
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border)}
|
||||
.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .15s;background:none;border:none}
|
||||
.tab:hover{color:var(--txt)}.tab.on{color:var(--txt)}
|
||||
.tab.on::after{content:"";position:absolute;bottom:-1px;left:16px;right:16px;height:2px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:2px;box-shadow:0 0 10px rgba(124,110,246,.6)}
|
||||
.badge{font-size:11px;font-weight:600;color:var(--muted);background:var(--panel-3);padding:1px 8px;border-radius:10px;font-variant-numeric:tabular-nums}
|
||||
.tab.on .badge{color:var(--accent-2);background:rgba(124,110,246,.16)}
|
||||
|
||||
.filter{display:flex;align-items:center;gap:9px;padding:10px 14px;border-bottom:1px solid var(--border-soft)}
|
||||
.filter .box{flex:1;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:8px 11px}
|
||||
.filter .box svg{width:14px;height:14px;flex:0 0 auto;color:var(--faint)}
|
||||
.filter .box input{border:none;background:none;outline:none;color:var(--txt);font-family:var(--font);font-size:12.5px;width:100%}
|
||||
.filter .box input::placeholder{color:var(--faint)}
|
||||
|
||||
.pushbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;cursor:pointer;font-size:12px;
|
||||
color:var(--add);background:rgba(62,207,142,.1);border:1px solid rgba(62,207,142,.28);transition:filter .15s}
|
||||
.pushbar:hover{filter:brightness(1.06)}
|
||||
.pushbar b{color:var(--add)}
|
||||
.pushdot{width:8px;height:8px;border-radius:50%;background:var(--add);box-shadow:0 0 8px var(--add);flex:0 0 auto}
|
||||
.pusharr{margin-left:auto;font-weight:700;font-size:14px}
|
||||
.chhead{display:flex;align-items:center;gap:10px;padding:9px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
|
||||
.chk{width:16px;height:16px;border-radius:6px;border:1.5px solid var(--accent);
|
||||
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));display:grid;place-items:center;flex:0 0 auto;box-shadow:0 0 8px rgba(124,110,246,.4)}
|
||||
.chk svg{width:10px;height:10px;color:#fff}
|
||||
.chk{cursor:pointer}
|
||||
.chk.empty{background:transparent;border-color:var(--border);box-shadow:none}
|
||||
.crumb{display:flex;align-items:center;gap:4px;font-family:var(--mono);font-size:12px;flex-wrap:wrap}
|
||||
.crumb .seg{color:var(--accent-2);cursor:pointer}.crumb .seg:hover{text-decoration:underline}
|
||||
.crumb .sep{color:var(--faint)}
|
||||
.scope-choose{margin:0 12px 12px;padding:11px;border:none;border-radius:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7);width:calc(100% - 24px)}
|
||||
.scope-choose:hover{filter:brightness(1.08)}
|
||||
.up{color:var(--muted)!important}
|
||||
|
||||
/* virtualized list */
|
||||
.files{flex:1;overflow-y:auto;min-height:0;position:relative}
|
||||
.vspace{position:relative;width:100%}
|
||||
.file{position:absolute;left:7px;right:7px;height:50px;display:flex;align-items:center;gap:11px;padding:0 12px;cursor:pointer;
|
||||
border-radius:11px;transition:background .13s}
|
||||
.file:hover{background:var(--hover)}
|
||||
.file.sel{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
|
||||
.file.sel::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
|
||||
background:linear-gradient(180deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 8px rgba(124,110,246,.6)}
|
||||
.fchk{width:15px;height:15px;border-radius:6px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
|
||||
.fchk.ck{background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));border-color:var(--accent)}
|
||||
.fchk svg{width:9px;height:9px;color:#fff}.fchk.off svg{display:none}
|
||||
.fthumb{width:30px;height:30px;border-radius:7px;flex:0 0 auto;object-fit:cover;background:var(--panel-3);border:1px solid var(--border)}
|
||||
.fthumb.ico{display:grid;place-items:center;font-size:13px;color:var(--faint)}
|
||||
.fthumb.ico.k-model{color:var(--accent-2)}
|
||||
.fname{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.fname .n{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.fname .p{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.stat{width:18px;height:18px;border-radius:6px;display:grid;place-items:center;font-size:11px;font-weight:700;flex:0 0 auto}
|
||||
.st-e{color:var(--edit);background:rgba(232,176,75,.12)}
|
||||
.st-a{color:var(--add);background:rgba(62,207,142,.12)}
|
||||
.st-d{color:var(--del);background:rgba(242,99,126,.12)}
|
||||
|
||||
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--faint);font-size:13px;padding:30px;gap:10px;white-space:pre-line;line-height:1.5}
|
||||
.empty svg{width:34px;height:34px;opacity:.5}
|
||||
|
||||
.commit{border-top:1px solid var(--border);padding:14px;display:flex;flex-direction:column;gap:9px;background:var(--sunk)}
|
||||
.addbtn{border:1px dashed var(--accent);border-radius:11px;padding:12px;font-size:13.5px;font-weight:600;color:var(--accent-2);cursor:pointer;
|
||||
background:rgba(124,110,246,.07);display:flex;align-items:center;justify-content:center;gap:8px;transition:background .15s}
|
||||
.addbtn:hover{background:rgba(124,110,246,.14)}.addbtn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.addbtn svg{width:16px;height:16px}
|
||||
.addhint{font-size:11px;color:var(--faint);text-align:center;line-height:1.5}
|
||||
.addhint b{color:var(--accent-2)}
|
||||
|
||||
/* scan (reconcile) button */
|
||||
.scanbtn{display:flex;align-items:center;gap:6px;font-size:11.5px;font-weight:500;color:var(--muted);cursor:pointer;
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:5px 10px;transition:.15s}
|
||||
.scanbtn:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
|
||||
.scanbtn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.scanbtn svg{width:13px;height:13px}
|
||||
.scanbtn.spinning svg{animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* auto-scan (background reconcile) banner */
|
||||
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
|
||||
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
|
||||
.scanbar .ldr{flex:0 0 auto}
|
||||
|
||||
/* submit progress modal */
|
||||
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
|
||||
.push-title{display:flex;align-items:center;justify-content:center;gap:10px;font-size:15px;font-weight:600;margin-bottom:6px}
|
||||
.push-count{font-size:12.5px;color:var(--muted);font-variant-numeric:tabular-nums;margin-bottom:14px}
|
||||
.push-track{height:8px;border-radius:6px;background:var(--panel-3);overflow:hidden}
|
||||
.push-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .3s ease;box-shadow:0 0 12px rgba(124,110,246,.6)}
|
||||
.push-hint{margin-top:14px;font-size:11px;color:var(--faint)}
|
||||
|
||||
/* right-click context menu */
|
||||
.ctxmenu{position:fixed;z-index:300;min-width:210px;padding:5px;border-radius:12px;
|
||||
background:var(--elevated);border:1px solid var(--border);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.03) inset}
|
||||
.ctxitem{padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
|
||||
.ctxitem:hover{background:var(--hover)}
|
||||
.ctxitem.danger{color:var(--del)}
|
||||
.ctxitem.danger:hover{background:rgba(242,99,126,.12)}
|
||||
.tzone.push .ic{color:var(--add)}
|
||||
.tzone.push .val{color:var(--add)}
|
||||
.commit .row{display:flex;gap:10px;align-items:center}
|
||||
.avatar{width:30px;height:30px;border-radius:50%;flex:0 0 auto;background:linear-gradient(145deg,#6d5cf0,#b07bf7);display:grid;place-items:center;font-size:12px;font-weight:700;color:#fff}
|
||||
.summary{flex:1;background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:8px 11px;font-size:13px;color:var(--txt);outline:none}
|
||||
.summary::placeholder{color:var(--faint)}
|
||||
.desc{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 11px;font-size:12.5px;color:var(--txt);min-height:46px;resize:none;outline:none;font-family:var(--font)}
|
||||
.desc::placeholder{color:var(--faint)}
|
||||
.submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));
|
||||
box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset;
|
||||
display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.submit:disabled{opacity:.5;cursor:not-allowed}
|
||||
.submit:not(:disabled):hover{filter:brightness(1.08)}.submit svg{width:15px;height:15px}.submit b{font-weight:700}
|
||||
|
||||
/* right */
|
||||
.diffhead{display:flex;align-items:center;gap:12px;padding:14px 20px;border-bottom:1px solid var(--border);flex:0 0 auto}
|
||||
.diffhead .ttl{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.diffhead .ttl .n{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.diffhead .ttl .n span{color:var(--faint);font-weight:500}
|
||||
.diffhead .ttl .m{font-size:11.5px;color:var(--muted);display:flex;gap:10px;align-items:center}
|
||||
.rev{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--accent-2)}
|
||||
.diffhead .tools{margin-left:auto;display:flex;gap:8px;flex:0 0 auto}
|
||||
.gbtn{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:9px;padding:7px 12px;cursor:pointer;transition:.15s}
|
||||
.gbtn:hover{color:var(--txt);border-color:var(--accent)}.gbtn svg{width:14px;height:14px}
|
||||
|
||||
.asset{flex:1;min-height:0;display:grid;grid-template-rows:1fr auto}
|
||||
.stage{position:relative;min-height:0;overflow:hidden;display:grid;place-items:center;
|
||||
background:radial-gradient(700px 500px at 50% 40%,rgba(124,110,246,.10),transparent 65%),
|
||||
repeating-linear-gradient(0deg,transparent,transparent 31px,var(--grid) 31px,var(--grid) 32px),
|
||||
repeating-linear-gradient(90deg,transparent,transparent 31px,var(--grid) 31px,var(--grid) 32px),var(--stage-bg)}
|
||||
.stage canvas{max-width:100%;max-height:100%;display:block}
|
||||
.stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip);
|
||||
border:1px solid var(--border);border-radius:20px;padding:5px 11px;display:flex;align-items:center;gap:7px}
|
||||
.stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)}
|
||||
.assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)}
|
||||
.mi{display:flex;flex-direction:column;gap:3px}
|
||||
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
|
||||
.mi .v{font-size:13.5px;font-weight:600;font-variant-numeric:tabular-nums}.mi .v.acc{color:var(--accent-2)}
|
||||
.nofile{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--faint);font-size:13px}
|
||||
.nofile svg{width:40px;height:40px;opacity:.4}
|
||||
|
||||
.files::-webkit-scrollbar{width:10px}
|
||||
.files::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* connect screen */
|
||||
.overlay{position:fixed;inset:0;z-index:100;display:grid;place-items:center;
|
||||
background:radial-gradient(800px 600px at 50% 30%,rgba(124,110,246,.14),transparent 60%),var(--win-b)}
|
||||
.card{width:400px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:30px 28px;box-shadow:var(--shadow)}
|
||||
.card .clogo{width:52px;height:52px;border-radius:14px;background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));
|
||||
display:grid;place-items:center;margin:0 auto 16px;box-shadow:0 0 30px rgba(124,110,246,.5)}
|
||||
.card .clogo svg{width:28px;height:28px}
|
||||
.card h2{text-align:center;font-size:19px;font-weight:700;margin-bottom:4px}
|
||||
.card .sub{text-align:center;font-size:12.5px;color:var(--muted);margin-bottom:22px}
|
||||
.fld{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
|
||||
.fld label{font-size:11px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
|
||||
.fld .inp{display:flex;align-items:center;gap:9px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;padding:10px 12px}
|
||||
.fld .inp:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.15)}
|
||||
.fld .inp svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.fld input,.fld select{border:none;background:none;outline:none;color:var(--txt);font-family:var(--font);font-size:13px;width:100%}
|
||||
.fld select{cursor:pointer}.fld select option{background:var(--elevated)}
|
||||
.err{background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.3);color:var(--del);border-radius:10px;
|
||||
padding:10px 12px;font-size:12px;margin-bottom:14px;display:flex;gap:8px;align-items:flex-start;line-height:1.5}
|
||||
.connect{width:100%;border:none;border-radius:11px;padding:12px;font-size:14px;font-weight:600;color:#fff;cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 10px 30px -8px rgba(124,110,246,.6);
|
||||
display:flex;align-items:center;justify-content:center;gap:9px}
|
||||
.connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px}
|
||||
.spin{animation:spin 1s linear infinite}
|
||||
.ldr{width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
|
||||
|
||||
/* diff view */
|
||||
.diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0}
|
||||
.dl{display:grid;grid-template-columns:4px 1fr;white-space:pre-wrap;word-break:break-word}
|
||||
.dl .g{width:4px}
|
||||
.dl .c{padding:0 16px 0 12px}
|
||||
.dl.add{background:rgba(62,207,142,.10)}.dl.add .c{color:var(--diff-add-c)}.dl.add .g{background:var(--add)}
|
||||
.dl.del{background:rgba(242,99,126,.10)}.dl.del .c{color:var(--diff-del-c)}.dl.del .g{background:var(--del)}
|
||||
.dl.hunk{background:rgba(124,110,246,.08)}.dl.hunk .c{color:var(--accent-2)}
|
||||
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* history */
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.hrow.click{cursor:pointer}
|
||||
.hrow:hover{background:var(--hover)}
|
||||
.hrow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
/* virtualized changelist-file row (History detail) */
|
||||
.crow{position:absolute;left:0;right:0;height:52px;display:flex;gap:12px;align-items:center;padding:0 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.crow:hover{background:var(--hover)}
|
||||
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
||||
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.hmeta{font-size:11px;color:var(--faint)}
|
||||
|
||||
/* 3d viewer toolbar */
|
||||
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
|
||||
.v3d-btn{width:32px;height:32px;border-radius:9px;border:1px solid var(--border);background:var(--chip);color:var(--muted);
|
||||
cursor:pointer;font-size:15px;line-height:1;display:grid;place-items:center;backdrop-filter:blur(6px);transition:.15s}
|
||||
.v3d-btn:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.v3d-btn.on{color:#fff;background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));border-color:var(--accent)}
|
||||
|
||||
/* viewer states */
|
||||
.viewer-msg{position:absolute;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
|
||||
.viewer-msg.err{color:var(--del);max-width:80%;text-align:center}
|
||||
.uasset-card{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;max-width:340px;padding:20px}
|
||||
.uasset-ic{width:64px;height:64px;border-radius:18px;display:grid;place-items:center;color:var(--accent-2);
|
||||
background:rgba(124,110,246,.12);border:1px solid var(--border)}
|
||||
.uasset-ic svg{width:34px;height:34px}
|
||||
.uasset-name{font-size:15px;font-weight:600}
|
||||
.uasset-note{font-size:12px;color:var(--muted);line-height:1.55}
|
||||
|
||||
/* picker / info modal (reuses modal-back) */
|
||||
.picker{width:440px;max-width:92%;max-height:74vh;background:var(--elevated);border:1px solid var(--border);border-radius:18px;
|
||||
box-shadow:0 30px 80px -20px rgba(0,0,0,.7);display:flex;flex-direction:column;overflow:hidden;animation:pop .18s cubic-bezier(.2,.9,.3,1.2)}
|
||||
.picker-head{padding:18px 20px 14px;border-bottom:1px solid var(--border-soft);display:flex;align-items:center;gap:10px}
|
||||
.picker-head h3{font-size:15px;font-weight:700}
|
||||
.picker-head > svg{width:20px;height:20px;flex:0 0 auto;color:var(--accent-2)}
|
||||
.picker-head .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center}
|
||||
.picker-head .x:hover{color:var(--txt)}
|
||||
.picker-list{overflow-y:auto;padding:8px}
|
||||
.picker-item{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:11px;cursor:pointer;transition:background .12s}
|
||||
.picker-item:hover{background:var(--hover)}
|
||||
.picker-item.on{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
|
||||
.picker-item .pi-ic{width:34px;height:34px;border-radius:9px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2);background:rgba(124,110,246,.1)}
|
||||
.picker-item .pi-ic svg{width:17px;height:17px}
|
||||
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
|
||||
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
|
||||
.info-body b{color:var(--txt)}
|
||||
.info-row{display:flex;justify-content:space-between;gap:16px;padding:7px 0;border-bottom:1px solid var(--border-soft)}
|
||||
.info-row:last-child{border-bottom:none}
|
||||
.info-row .rk{color:var(--faint)}.info-row .rv{color:var(--txt);font-weight:500;font-family:var(--mono);font-size:12px}
|
||||
|
||||
/* confirm modal */
|
||||
.modal-back{position:fixed;inset:0;z-index:210;display:grid;place-items:center;
|
||||
background:rgba(6,6,10,.55);backdrop-filter:blur(3px);animation:fadein .15s ease}
|
||||
.modal-card{width:380px;max-width:90%;background:var(--elevated);border:1px solid var(--border);border-radius:18px;
|
||||
padding:26px 26px 20px;box-shadow:0 30px 80px -20px rgba(0,0,0,.7);text-align:center;animation:pop .18s cubic-bezier(.2,.9,.3,1.2)}
|
||||
.modal-icon{width:52px;height:52px;border-radius:14px;margin:0 auto 16px;display:grid;place-items:center;
|
||||
background:rgba(124,110,246,.14);color:var(--accent-2)}
|
||||
.modal-icon svg{width:26px;height:26px}
|
||||
.modal-icon.danger{background:rgba(242,99,126,.14);color:var(--del)}
|
||||
.modal-card h3{font-size:17px;font-weight:700;margin-bottom:8px}
|
||||
.modal-card p{font-size:13px;line-height:1.55;color:var(--muted);white-space:pre-wrap;margin-bottom:22px}
|
||||
.modal-actions{display:flex;gap:10px}
|
||||
.mbtn{flex:1;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn.ghost{background:var(--panel);border:1px solid var(--border);color:var(--txt)}
|
||||
.mbtn.ghost:hover{background:var(--hover)}
|
||||
.mbtn.primary{color:#fff;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7)}
|
||||
.mbtn.danger{color:#fff;background:linear-gradient(135deg,#f2637e,#d23a5c);box-shadow:0 8px 22px -8px rgba(242,99,126,.6)}
|
||||
.mbtn.primary:hover,.mbtn.danger:hover{filter:brightness(1.08)}
|
||||
@keyframes fadein{from{opacity:0}to{opacity:1}}
|
||||
@keyframes pop{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
|
||||
|
||||
/* drag & drop overlay */
|
||||
.dropzone{position:fixed;inset:0;z-index:150;display:grid;place-items:center;
|
||||
background:rgba(124,110,246,.14);backdrop-filter:blur(2px);pointer-events:none}
|
||||
.dropinner{display:flex;flex-direction:column;align-items:center;gap:14px;color:var(--accent-2);
|
||||
border:2px dashed var(--accent);border-radius:20px;padding:40px 60px;background:var(--elevated)}
|
||||
.dropinner svg{width:44px;height:44px}
|
||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||
|
||||
/* toast */
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
|
||||
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
|
||||
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
||||
1040
src/App.tsx
Normal file
205
src/ModelViewer.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||||
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
|
||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
||||
import { ThreeMFLoader } from "three/examples/jsm/loaders/3MFLoader.js";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { p4 } from "./p4";
|
||||
|
||||
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
|
||||
|
||||
export default function ModelViewer({ depot, name }: { depot: string; name: string }) {
|
||||
const mount = useRef<HTMLDivElement>(null);
|
||||
const api = useRef<Api | null>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
|
||||
const [err, setErr] = useState("");
|
||||
const [stats, setStats] = useState({ tris: 0, verts: 0, meshes: 0, mats: 0 });
|
||||
const [wire, setWire] = useState(false);
|
||||
const [grid, setGrid] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let raf = 0;
|
||||
const el = mount.current!;
|
||||
setStatus("loading"); setErr("");
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(el.clientWidth || 600, el.clientHeight || 400);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.05;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
el.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
// image-based lighting (soft studio environment) — this is what makes PBR look good
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000);
|
||||
|
||||
// key light with shadows + soft fill
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||
key.position.set(5, 8, 6);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.bias = -0.0004;
|
||||
scene.add(key);
|
||||
scene.add(new THREE.DirectionalLight(0x8b7bf7, 0.35).translateX(-6));
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true; controls.dampingFactor = 0.08;
|
||||
controls.autoRotate = true; controls.autoRotateSpeed = 1.1;
|
||||
renderer.domElement.addEventListener("pointerdown", () => (controls.autoRotate = false));
|
||||
|
||||
// ground + grid (added after fit, sized to model)
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1, 1),
|
||||
new THREE.ShadowMaterial({ opacity: 0.28 })
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
|
||||
let gridHelper: THREE.GridHelper | null = null;
|
||||
let home = { pos: new THREE.Vector3(3, 2, 3), tgt: new THREE.Vector3() };
|
||||
|
||||
const ext = (name.toLowerCase().split(".").pop() || "");
|
||||
|
||||
function collectStats(obj: THREE.Object3D) {
|
||||
let tris = 0, verts = 0, meshes = 0; const mats = new Set<THREE.Material>();
|
||||
obj.traverse((c) => {
|
||||
const m = c as THREE.Mesh;
|
||||
if ((m as unknown as { isMesh?: boolean }).isMesh && m.geometry) {
|
||||
meshes++; const pos = m.geometry.getAttribute("position");
|
||||
verts += pos ? pos.count : 0;
|
||||
tris += m.geometry.index ? m.geometry.index.count / 3 : (pos ? pos.count / 3 : 0);
|
||||
(Array.isArray(m.material) ? m.material : [m.material]).forEach((x) => x && mats.add(x));
|
||||
}
|
||||
});
|
||||
setStats({ tris: Math.round(tris), verts, meshes, mats: mats.size });
|
||||
}
|
||||
|
||||
function place(obj: THREE.Object3D) {
|
||||
obj.traverse((c) => {
|
||||
const m = c as THREE.Mesh;
|
||||
if ((m as unknown as { isMesh?: boolean }).isMesh) {
|
||||
m.castShadow = true; m.receiveShadow = true;
|
||||
const has = m.material && !(Array.isArray(m.material) && m.material.length === 0);
|
||||
if (!has) m.material = new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, metalness: 0.05 });
|
||||
}
|
||||
});
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
obj.position.sub(center);
|
||||
obj.position.y += size.y / 2; // sit on the ground
|
||||
scene.add(obj);
|
||||
|
||||
// ground + grid
|
||||
ground.scale.set(maxDim * 8, maxDim * 8, 1);
|
||||
gridHelper = new THREE.GridHelper(maxDim * 6, 30, 0x7c6ef6, 0x333344);
|
||||
(gridHelper.material as THREE.Material).transparent = true;
|
||||
(gridHelper.material as THREE.Material).opacity = 0.35;
|
||||
scene.add(gridHelper); gridHelper.visible = grid;
|
||||
|
||||
// shadow camera frustum
|
||||
const s = maxDim * 2.5;
|
||||
Object.assign(key.shadow.camera, { left: -s, right: s, top: s, bottom: -s, near: 0.1, far: maxDim * 40 });
|
||||
key.shadow.camera.updateProjectionMatrix();
|
||||
|
||||
const dist = maxDim * 2.1;
|
||||
home = { pos: new THREE.Vector3(dist * 0.8, dist * 0.65, dist), tgt: new THREE.Vector3(0, size.y / 2, 0) };
|
||||
camera.position.copy(home.pos);
|
||||
camera.near = maxDim / 200; camera.far = maxDim * 200; camera.updateProjectionMatrix();
|
||||
controls.target.copy(home.tgt); controls.maxDistance = maxDim * 25; controls.update();
|
||||
|
||||
collectStats(obj);
|
||||
setStatus("ok");
|
||||
|
||||
// expose controls to React buttons
|
||||
api.current = {
|
||||
toggleWire: (b) => obj.traverse((c) => {
|
||||
const m = c as THREE.Mesh; if (!(m as unknown as { isMesh?: boolean }).isMesh) return;
|
||||
(Array.isArray(m.material) ? m.material : [m.material]).forEach((mt) => { const s = mt as THREE.MeshStandardMaterial; if (s) s.wireframe = b; });
|
||||
}),
|
||||
toggleGrid: (b) => { if (gridHelper) gridHelper.visible = b; },
|
||||
reset: () => { camera.position.copy(home.pos); controls.target.copy(home.tgt); controls.update(); controls.autoRotate = false; },
|
||||
};
|
||||
}
|
||||
|
||||
function fail(e: unknown) { if (!disposed) { setErr(String((e as { message?: string })?.message || e)); setStatus("error"); } }
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const buf = await p4.readDepot(depot);
|
||||
if (disposed) return;
|
||||
const manager = new THREE.LoadingManager();
|
||||
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
|
||||
else if (ext === "obj") place(new OBJLoader(manager).parse(new TextDecoder().decode(buf)));
|
||||
else if (ext === "fbx") place(new FBXLoader(manager).parse(buf, ""));
|
||||
else if (ext === "dae") { const c = new ColladaLoader(manager).parse(new TextDecoder().decode(buf), ""); if (c && c.scene) place(c.scene); else fail("Collada: пустая сцена"); }
|
||||
else if (ext === "3mf") place(new ThreeMFLoader(manager).parse(buf));
|
||||
else if (ext === "stl") place(new THREE.Mesh(new STLLoader().parse(buf), new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5 })));
|
||||
else if (ext === "ply") { const g = new PLYLoader().parse(buf); g.computeVertexNormals(); place(new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, vertexColors: !!g.getAttribute("color") }))); }
|
||||
else { setErr("Формат не поддерживается вьювером"); setStatus("error"); }
|
||||
} catch (e) { fail(e); }
|
||||
})();
|
||||
|
||||
const animate = () => { controls.update(); renderer.render(scene, camera); raf = requestAnimationFrame(animate); };
|
||||
animate();
|
||||
|
||||
const onResize = () => {
|
||||
const w = el.clientWidth || 600, h = el.clientHeight || 400;
|
||||
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||
};
|
||||
const ro = new ResizeObserver(onResize); ro.observe(el);
|
||||
|
||||
return () => {
|
||||
disposed = true; cancelAnimationFrame(raf); ro.disconnect(); controls.dispose(); pmrem.dispose();
|
||||
scene.traverse((o) => {
|
||||
const m = o as THREE.Mesh;
|
||||
if (m.geometry) m.geometry.dispose();
|
||||
const mat = m.material; if (mat) (Array.isArray(mat) ? mat : [mat]).forEach((x) => x && x.dispose());
|
||||
});
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === el) el.removeChild(renderer.domElement);
|
||||
api.current = null;
|
||||
};
|
||||
// eslint-disable-next-line
|
||||
}, [depot, name]);
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString("ru");
|
||||
return (
|
||||
<div className="asset">
|
||||
<div className="stage" style={{ padding: 0, position: "relative" }}>
|
||||
<div ref={mount} style={{ position: "absolute", inset: 0 }} />
|
||||
<span className="lbl2">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /><path d="m3 7 9 5 9-5" stroke="currentColor" strokeWidth="1.6" /></svg>
|
||||
3D · {(name.split(".").pop() || "").toUpperCase()}
|
||||
</span>
|
||||
{status === "ok" && (
|
||||
<div className="v3d-tools">
|
||||
<button className={"v3d-btn" + (wire ? " on" : "")} onClick={() => { setWire(!wire); api.current?.toggleWire(!wire); }} title="Wireframe">◫</button>
|
||||
<button className={"v3d-btn" + (grid ? " on" : "")} onClick={() => { setGrid(!grid); api.current?.toggleGrid(!grid); }} title="Сетка">▦</button>
|
||||
<button className="v3d-btn" onClick={() => api.current?.reset()} title="Сбросить вид">⟲</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "loading" && <div className="viewer-msg"><span className="ldr" />Загрузка модели…</div>}
|
||||
{status === "error" && <div className="viewer-msg err">{err}</div>}
|
||||
{status === "ok" && <span className="hint">тащи — вращать · колесо — зум · ПКМ — панорама</span>}
|
||||
</div>
|
||||
<div className="assetmeta">
|
||||
<div className="mi"><span className="k">Треугольники</span><span className="v">{fmt(stats.tris)}</span></div>
|
||||
<div className="mi"><span className="k">Вершины</span><span className="v">{fmt(stats.verts)}</span></div>
|
||||
<div className="mi"><span className="k">Меши · Материалы</span><span className="v">{stats.meshes} · {stats.mats}</span></div>
|
||||
<div className="mi"><span className="k">Рендер</span><span className="v acc">three.js · PBR</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
9
src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
159
src/p4.ts
Normal file
@ -0,0 +1,159 @@
|
||||
// Typed wrappers around the Rust p4 commands.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface P4Info {
|
||||
serverAddress?: string;
|
||||
serverVersion?: string;
|
||||
userName?: string;
|
||||
clientName?: string;
|
||||
clientRoot?: string;
|
||||
serverUptime?: string;
|
||||
serverDate?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenedFile {
|
||||
depotFile?: string;
|
||||
clientFile?: string;
|
||||
rev?: string;
|
||||
haveRev?: string;
|
||||
action?: string; // edit | add | delete | branch | integrate | move/add | move/delete
|
||||
type?: string; // text | binary | ...
|
||||
change?: string; // "default" or a number
|
||||
user?: string;
|
||||
client?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
client?: string;
|
||||
Root?: string;
|
||||
Host?: string;
|
||||
Stream?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
change?: string;
|
||||
time?: string;
|
||||
user?: string;
|
||||
client?: string;
|
||||
status?: string;
|
||||
desc?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export const p4 = {
|
||||
clients: (port: string, user: string) =>
|
||||
invoke<Client[]>("p4_clients", { port, user }),
|
||||
login: (port: string, user: string, password: string, client: string) =>
|
||||
invoke<P4Info>("p4_login", { port, user, password, client }),
|
||||
restore: (port: string, user: string, client: string) =>
|
||||
invoke<P4Info>("p4_restore", { port, user, client }),
|
||||
info: () => invoke<P4Info>("p4_info"),
|
||||
opened: (scope = "") => invoke<OpenedFile[]>("p4_opened", { scope }),
|
||||
dirs: (path = "") => invoke<Dir[]>("p4_dirs", { path }),
|
||||
changes: () => invoke<Change[]>("p4_changes"),
|
||||
history: (path: string) => invoke<Change[]>("p4_history", { path }),
|
||||
disconnect: () => invoke<void>("p4_disconnect"),
|
||||
sync: (scope = "") => invoke<string>("p4_sync", { scope }),
|
||||
diff: (file: string) => invoke<string>("p4_diff", { file }),
|
||||
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
|
||||
submit: (description: string, files: string[]) =>
|
||||
invoke<string>("p4_submit", { description, files }),
|
||||
commit: (description: string, files: string[]) =>
|
||||
invoke<string>("p4_commit", { description, files }),
|
||||
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
|
||||
add: (files: string[]) => invoke<OpenedFile[]>("p4_add", { files }),
|
||||
edit: (files: string[]) => invoke<OpenedFile[]>("p4_edit", { files }),
|
||||
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
||||
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
|
||||
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
|
||||
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
||||
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||
readDepot: async (depot: string): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("read_depot", { depot });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
|
||||
return r as ArrayBuffer;
|
||||
},
|
||||
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
|
||||
};
|
||||
|
||||
export interface Dir { dir?: string; [k: string]: unknown }
|
||||
// depot path -> short leaf name (last segment)
|
||||
export function leaf(path?: string): string {
|
||||
if (!path) return "";
|
||||
const p = path.replace(/\/+$/, "");
|
||||
return p.slice(p.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
// a submitted changelist with indexed file fields (depotFile0, action0, ...)
|
||||
export interface Describe extends Change { [k: string]: unknown }
|
||||
export function describeFiles(d: Describe): { depotFile: string; action: string; rev: string }[] {
|
||||
const out: { depotFile: string; action: string; rev: string }[] = [];
|
||||
for (let i = 0; ; i++) {
|
||||
const dp = d[`depotFile${i}`] as string | undefined;
|
||||
if (!dp) break;
|
||||
out.push({ depotFile: dp, action: (d[`action${i}`] as string) || "edit", rev: (d[`rev${i}`] as string) || "" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- session persistence (server/user/workspace — никогда не пароль) ---
|
||||
export interface Session { server: string; user: string; client: string }
|
||||
const SESSION_KEY = "exd-session";
|
||||
export function saveSession(s: Session) {
|
||||
try { localStorage.setItem(SESSION_KEY, JSON.stringify(s)); } catch {}
|
||||
}
|
||||
export function loadSession(): Session | null {
|
||||
try { const r = localStorage.getItem(SESSION_KEY); return r ? JSON.parse(r) : null; } catch { return null; }
|
||||
}
|
||||
export function clearSession() {
|
||||
try { localStorage.removeItem(SESSION_KEY); } catch {}
|
||||
}
|
||||
|
||||
// --- working-folder scope persistence (per workspace) ---
|
||||
export function saveScope(client: string, path: string) {
|
||||
try { localStorage.setItem("exd-scope-" + client, path); } catch {}
|
||||
}
|
||||
export function loadScope(client: string): string {
|
||||
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
|
||||
}
|
||||
|
||||
// format a unix-epoch string as a short local datetime
|
||||
export function fmtTime(t?: string): string {
|
||||
if (!t) return "";
|
||||
const n = Number(t);
|
||||
if (!n) return "";
|
||||
const d = new Date(n * 1000);
|
||||
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// action -> single-char status + css class
|
||||
export function statusOf(action?: string): { ch: string; cls: string } {
|
||||
const a = (action || "").toLowerCase();
|
||||
if (a.includes("add")) return { ch: "+", cls: "st-a" };
|
||||
if (a.includes("delete")) return { ch: "−", cls: "st-d" };
|
||||
return { ch: "±", cls: "st-e" }; // edit / integrate / branch / move
|
||||
}
|
||||
|
||||
// depot or client path -> { name, dir }
|
||||
export function splitPath(p?: string): { name: string; dir: string } {
|
||||
if (!p) return { name: "(unknown)", dir: "" };
|
||||
const norm = p.replace(/\\/g, "/");
|
||||
const i = norm.lastIndexOf("/");
|
||||
return { name: norm.slice(i + 1), dir: norm.slice(0, i + 1) };
|
||||
}
|
||||
|
||||
// classify by extension for the preview panel
|
||||
export type Kind = "model" | "uasset" | "image" | "code";
|
||||
export function kindOf(name: string): Kind {
|
||||
const n = name.toLowerCase();
|
||||
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
|
||||
if (/\.(uasset|umap)$/.test(n)) return "uasset";
|
||||
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
|
||||
return "code";
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||