Compare commits
3 Commits
5d647f8e92
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5652f489a2 | |||
| 9c24d45bf9 | |||
| ec8f31c654 |
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
@ -1456,6 +1457,15 @@
|
|||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.4",
|
"version": "0.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
|||||||
155
src-tauri/Cargo.lock
generated
155
src-tauri/Cargo.lock
generated
@ -343,6 +343,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@ -960,7 +966,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.2.4"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"serde",
|
"serde",
|
||||||
@ -968,6 +974,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
@ -1733,6 +1740,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@ -2022,6 +2042,20 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"time",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@ -2081,6 +2115,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.19.3"
|
||||||
@ -2132,6 +2176,20 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"serde",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -2634,6 +2692,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -2702,6 +2769,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@ -2732,6 +2805,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@ -3593,6 +3695,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@ -3747,6 +3850,25 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-notification"
|
||||||
|
version = "2.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"notify-rust",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
@ -3912,6 +4034,17 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"windows",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@ -5299,6 +5432,26 @@ dependencies = [
|
|||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.2.4"
|
version = "0.3.1"
|
||||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||||
authors = ["Exbyte Studios"]
|
authors = ["Exbyte Studios"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@ -18,8 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.7.1"
|
||||||
|
|||||||
@ -11,8 +11,13 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default"
|
"process:default",
|
||||||
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -324,6 +324,116 @@ async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>,
|
|||||||
run_json(&conn, &["dirs", &spec])
|
run_json(&conn, &["dirs", &spec])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Browse a depot folder AND weigh it: returns the immediate children of `path`
|
||||||
|
/// (empty = depot roots) — each sub-directory with its recursive file count and
|
||||||
|
/// total byte size (`p4 sizes -s`), and each file directly inside with its size
|
||||||
|
/// (`p4 sizes`). Powers the Depot side of the file/size explorer. Children are
|
||||||
|
/// returned unsorted; the UI sorts by size.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn depot_ls(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let base = path.trim_end_matches(['/', '\\']).to_string();
|
||||||
|
let mut out: Vec<Value> = Vec::new();
|
||||||
|
|
||||||
|
// 1) sub-directories
|
||||||
|
let dir_spec = if base.is_empty() { "//*".to_string() } else { format!("{}/*", base) };
|
||||||
|
let dirs = run_json(&conn, &["dirs", &dir_spec]).unwrap_or_default();
|
||||||
|
let dir_paths: Vec<String> = dirs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| d.get("dir").and_then(|s| s.as_str()).map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 2) recursive size summary for every sub-directory in one call
|
||||||
|
// (`p4 sizes -s //a/... //b/... …` → one summary line per argument)
|
||||||
|
if !dir_paths.is_empty() {
|
||||||
|
let mut args: Vec<String> = vec!["sizes".into(), "-s".into()];
|
||||||
|
for d in &dir_paths {
|
||||||
|
args.push(format!("{}/...", d));
|
||||||
|
}
|
||||||
|
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||||
|
let summaries = run_json(&conn, &arg_refs).unwrap_or_default();
|
||||||
|
// map "//a/..." → (fileCount, fileSize)
|
||||||
|
let mut by_path: std::collections::HashMap<String, (u64, u64)> = std::collections::HashMap::new();
|
||||||
|
for s in &summaries {
|
||||||
|
let p = s.get("path").and_then(|x| x.as_str()).unwrap_or("").trim_end_matches("/...").trim_end_matches("...").trim_end_matches('/').to_string();
|
||||||
|
let fc = s.get("fileCount").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
|
||||||
|
let fsz = s.get("fileSize").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
|
||||||
|
if !p.is_empty() { by_path.insert(p, (fc, fsz)); }
|
||||||
|
}
|
||||||
|
for d in &dir_paths {
|
||||||
|
let key = d.trim_end_matches('/').to_string();
|
||||||
|
let (fc, fsz) = by_path.get(&key).copied().unwrap_or((0, 0));
|
||||||
|
let name = key.rsplit('/').next().unwrap_or(&key).to_string();
|
||||||
|
out.push(serde_json::json!({ "name": name, "path": key, "isDir": true, "size": fsz, "fileCount": fc }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) files directly inside this folder (non-recursive) with their sizes
|
||||||
|
if !base.is_empty() {
|
||||||
|
let file_spec = format!("{}/*", base);
|
||||||
|
let files = run_json(&conn, &["sizes", &file_spec]).unwrap_or_default();
|
||||||
|
for f in &files {
|
||||||
|
let dp = f.get("depotFile").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
if dp.is_empty() { continue; }
|
||||||
|
let sz = f.get("fileSize").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
|
||||||
|
let name = dp.rsplit('/').next().unwrap_or(&dp).to_string();
|
||||||
|
out.push(serde_json::json!({ "name": name, "path": dp, "isDir": false, "size": sz, "fileCount": 1 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively sum the byte size of a local directory tree, ignoring
|
||||||
|
/// unreadable entries. Used by the Workspace side of the explorer.
|
||||||
|
fn dir_size_of(p: &std::path::Path) -> u64 {
|
||||||
|
let mut total = 0u64;
|
||||||
|
if let Ok(rd) = std::fs::read_dir(p) {
|
||||||
|
for e in rd.flatten() {
|
||||||
|
match e.file_type() {
|
||||||
|
Ok(ft) if ft.is_dir() => total += dir_size_of(&e.path()),
|
||||||
|
Ok(ft) if ft.is_file() => total += e.metadata().map(|m| m.len()).unwrap_or(0),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse a local (Workspace) folder AND weigh it: immediate children of `path`
|
||||||
|
/// (empty = the workspace root), each sub-folder with its recursive byte size,
|
||||||
|
/// each file with its size. Confined to the workspace root.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fs_ls(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let dir = if path.trim().is_empty() {
|
||||||
|
if conn.root.is_empty() { return Err("No workspace root for this connection".into()); }
|
||||||
|
conn.root.clone()
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
};
|
||||||
|
ensure_under_root(&conn, &dir)?;
|
||||||
|
let mut out: Vec<Value> = Vec::new();
|
||||||
|
let rd = std::fs::read_dir(&dir).map_err(|e| format!("Cannot read folder: {e}"))?;
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
let path_str = p.to_string_lossy().to_string();
|
||||||
|
match e.file_type() {
|
||||||
|
Ok(ft) if ft.is_dir() => {
|
||||||
|
let sz = dir_size_of(&p);
|
||||||
|
out.push(serde_json::json!({ "name": name, "path": path_str, "isDir": true, "size": sz, "fileCount": 0 }));
|
||||||
|
}
|
||||||
|
Ok(ft) if ft.is_file() => {
|
||||||
|
let sz = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
out.push(serde_json::json!({ "name": name, "path": path_str, "isDir": false, "size": sz, "fileCount": 1 }));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pending changelists for the current user that actually contain files.
|
/// Pending changelists for the current user that actually contain files.
|
||||||
/// Empty pending changelists (leftovers from a reverted/aborted commit) are
|
/// Empty pending changelists (leftovers from a reverted/aborted commit) are
|
||||||
/// cleaned up so they never show a phantom "ready to Submit" or fail submit.
|
/// cleaned up so they never show a phantom "ready to Submit" or fail submit.
|
||||||
@ -1914,10 +2024,37 @@ async fn p4_reopen_to(state: State<'_, AppState>, change: String, files: Vec<Str
|
|||||||
async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<Value, String> {
|
async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<Value, String> {
|
||||||
let conn = current(&state)?;
|
let conn = current(&state)?;
|
||||||
let spec = if scope.is_empty() { "//...".to_string() } else { scope_spec(&scope) };
|
let spec = if scope.is_empty() { "//...".to_string() } else { scope_spec(&scope) };
|
||||||
let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", &spec])?;
|
// -l → full description so notifications can show what the teammate wrote
|
||||||
|
let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", "-l", &spec])?;
|
||||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far the workspace is behind the server: previews `p4 sync -n` and counts
|
||||||
|
/// the files that would update (added / updated / deleted), plus a few sample
|
||||||
|
/// names. `{count, sample}`; count 0 = up to date. Non-destructive.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_behind(state: State<'_, AppState>, scope: String) -> Result<Value, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let spec = scope_spec(&scope);
|
||||||
|
match run_json(&conn, &["sync", "-n", &spec]) {
|
||||||
|
Ok(v) => {
|
||||||
|
let files: Vec<&Value> = v.iter().filter(|o| o.get("depotFile").is_some() || o.get("clientFile").is_some()).collect();
|
||||||
|
let sample: Vec<String> = files.iter().take(6).filter_map(|o| {
|
||||||
|
o.get("depotFile").or_else(|| o.get("clientFile")).and_then(|s| s.as_str()).map(|s| s.rsplit(['/', '\\']).next().unwrap_or(s).to_string())
|
||||||
|
}).collect();
|
||||||
|
Ok(serde_json::json!({ "count": files.len(), "sample": sample }))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let l = e.to_lowercase();
|
||||||
|
if l.contains("up-to-date") || l.contains("no such file") || l.contains("no such area") || l.contains("not in client") {
|
||||||
|
Ok(serde_json::json!({ "count": 0, "sample": [] }))
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Preview `p4 clean` — files that would be deleted (local extras), restored
|
/// Preview `p4 clean` — files that would be deleted (local extras), restored
|
||||||
/// (locally deleted) or refreshed (locally modified but not opened) to make the
|
/// (locally deleted) or refreshed (locally modified but not opened) to make the
|
||||||
/// workspace exactly match the depot. Non-destructive (`-n`).
|
/// workspace exactly match the depot. Non-destructive (`-n`).
|
||||||
@ -1943,6 +2080,93 @@ async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<Str
|
|||||||
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
|
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total byte weight of one or more changelists (`p4 sizes -s //scope/...@=CH`):
|
||||||
|
/// for each change, the sum of the file revisions submitted in it. Returns
|
||||||
|
/// `{change, size}` per input change. Used to show commit weight in History.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_change_sizes(state: State<'_, AppState>, changes: Vec<String>, scope: String) -> Result<Vec<Value>, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let root = if scope.trim().is_empty() { "//".to_string() } else { format!("{}/", scope.trim_end_matches(['/', '\\'])) };
|
||||||
|
let mut out: Vec<Value> = Vec::new();
|
||||||
|
for ch in changes.iter().map(|c| c.trim()).filter(|c| !c.is_empty()) {
|
||||||
|
let spec = format!("{}...@={}", root, ch);
|
||||||
|
let size = run_json(&conn, &["sizes", "-s", &spec])
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.into_iter().next())
|
||||||
|
.and_then(|o| o.get("fileSize").and_then(|s| s.as_str()).and_then(|s| s.parse::<u64>().ok()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
out.push(serde_json::json!({ "change": ch, "size": size }));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview offline work (`p4 reconcile -n -e -a -d`): scan the workspace on disk
|
||||||
|
/// and report what changed while disconnected — files edited (writable but not
|
||||||
|
/// opened), added (present locally, unknown to the depot) or deleted (in the
|
||||||
|
/// depot but missing locally). Non-destructive (`-n`): nothing is opened yet.
|
||||||
|
/// This is P4V's "Reconcile Offline Work". Returns one object per file with an
|
||||||
|
/// `action` field (edit / add / delete).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_reconcile_preview(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
run_json(&conn, &["reconcile", "-n", "-e", "-a", "-d", &scope_spec(&scope)]).or_else(|e| {
|
||||||
|
let l = e.to_lowercase();
|
||||||
|
if l.contains("no file") || l.contains("up-to-date") || l.contains("no such") || l.contains("no differing") || l.contains("- no ") {
|
||||||
|
Ok(Vec::new())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the offline reconcile for the picked files only (`p4 reconcile -e -a -d
|
||||||
|
/// file…`): opens the selected edits/adds/deletes into the pending changelist so
|
||||||
|
/// they can be reviewed and submitted. Paths are the depot paths from the preview.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_reconcile_apply(state: State<'_, AppState>, paths: Vec<String>) -> Result<Vec<Value>, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let clean: Vec<String> = paths.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect();
|
||||||
|
if clean.is_empty() {
|
||||||
|
return Err("No files selected".into());
|
||||||
|
}
|
||||||
|
let mut args: Vec<&str> = vec!["reconcile", "-e", "-a", "-d"];
|
||||||
|
for p in &clean {
|
||||||
|
args.push(p.as_str());
|
||||||
|
}
|
||||||
|
run_json(&conn, &args).or_else(|e| {
|
||||||
|
let l = e.to_lowercase();
|
||||||
|
if l.contains("no file") || l.contains("up-to-date") || l.contains("no differing") {
|
||||||
|
Ok(Vec::new())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle the read-only attribute on local working files so they can be edited
|
||||||
|
/// while offline (Perforce syncs files read-only; clearing the flag lets an
|
||||||
|
/// editor save into them before a checkout is possible). Confined to the
|
||||||
|
/// workspace root. Reports how many files were changed.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_set_writable(state: State<'_, AppState>, paths: Vec<String>, writable: bool) -> Result<String, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let mut done = 0usize;
|
||||||
|
for p in paths.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
|
||||||
|
ensure_under_root(&conn, p)?;
|
||||||
|
let meta = std::fs::metadata(p).map_err(|e| format!("File not found: {e}"))?;
|
||||||
|
let mut perms = meta.permissions();
|
||||||
|
#[allow(clippy::permissions_set_readonly_false)]
|
||||||
|
perms.set_readonly(!writable);
|
||||||
|
std::fs::set_permissions(p, perms).map_err(|e| format!("Cannot change {p}: {e}"))?;
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
|
Ok(if writable {
|
||||||
|
format!("{done} file(s) made writable")
|
||||||
|
} else {
|
||||||
|
format!("{done} file(s) set read-only")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Full revision history of a single file (`p4 filelog -l`), including
|
/// Full revision history of a single file (`p4 filelog -l`), including
|
||||||
/// per-revision changelist, action, user, time and description. Returns the
|
/// per-revision changelist, action, user, time and description. Returns the
|
||||||
/// tagged object with rev0/change0/action0/… arrays.
|
/// tagged object with rev0/change0/action0/… arrays.
|
||||||
@ -2234,6 +2458,7 @@ async fn save_ai_key(key: String) -> Result<(), String> {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_dialog::init());
|
.plugin(tauri_plugin_dialog::init());
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@ -2242,9 +2467,56 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_process::init());
|
.plugin(tauri_plugin_process::init());
|
||||||
}
|
}
|
||||||
builder
|
builder
|
||||||
|
// clicking the window's X hides to the tray instead of quitting, so
|
||||||
|
// notifications keep arriving. Real quit is the tray menu's "Quit".
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
use tauri::Emitter;
|
||||||
|
if window.label() == "main" {
|
||||||
|
let _ = window.hide();
|
||||||
|
let _ = window.emit("tray-hidden", ());
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
use tauri::menu::{Menu, MenuItem};
|
||||||
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
use tauri::Manager;
|
||||||
// stash the app handle so p4 helpers can emit the live command log
|
// stash the app handle so p4 helpers can emit the live command log
|
||||||
let _ = APP.set(app.handle().clone());
|
let _ = APP.set(app.handle().clone());
|
||||||
|
|
||||||
|
// ---- system tray: keeps the app alive & notifying when the window is closed ----
|
||||||
|
let show = MenuItem::with_id(app, "show", "Open Exbyte Depot", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Quit Exbyte Depot", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||||
|
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.tooltip("Exbyte Depot — Perforce")
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.show();
|
||||||
|
let _ = w.unminimize();
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.show();
|
||||||
|
let _ = w.unminimize();
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.manage(AppState::default())
|
.manage(AppState::default())
|
||||||
@ -2301,6 +2573,9 @@ pub fn run() {
|
|||||||
p4_filelog,
|
p4_filelog,
|
||||||
p4_clean_preview,
|
p4_clean_preview,
|
||||||
p4_clean_apply,
|
p4_clean_apply,
|
||||||
|
p4_reconcile_preview,
|
||||||
|
p4_reconcile_apply,
|
||||||
|
p4_set_writable,
|
||||||
p4_submit_shelved,
|
p4_submit_shelved,
|
||||||
p4_submit_opts,
|
p4_submit_opts,
|
||||||
p4_ignore_read,
|
p4_ignore_read,
|
||||||
@ -2332,6 +2607,10 @@ pub fn run() {
|
|||||||
read_ai_key,
|
read_ai_key,
|
||||||
save_ai_key,
|
save_ai_key,
|
||||||
p4_dirs,
|
p4_dirs,
|
||||||
|
depot_ls,
|
||||||
|
fs_ls,
|
||||||
|
p4_change_sizes,
|
||||||
|
p4_behind,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Exbyte Depot",
|
"productName": "Exbyte Depot",
|
||||||
"mainBinaryName": "Exbyte Depot",
|
"mainBinaryName": "Exbyte Depot",
|
||||||
"version": "0.2.4",
|
"version": "0.3.1",
|
||||||
"identifier": "com.bonchellon.exbyte-depot",
|
"identifier": "com.bonchellon.exbyte-depot",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@ -24,8 +24,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
89
src/App.css
89
src/App.css
@ -489,6 +489,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.summary::placeholder{color:var(--faint)}
|
.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{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)}
|
.desc::placeholder{color:var(--faint)}
|
||||||
|
.summary:focus,.desc:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.14)}
|
||||||
.submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
|
.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));
|
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;
|
box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset;
|
||||||
@ -527,6 +528,15 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip);
|
.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}
|
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)}
|
.stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)}
|
||||||
|
/* audio / video preview */
|
||||||
|
.mediaplayer{width:min(460px,86%)}
|
||||||
|
.mediaplayer.vid{width:auto;max-width:92%;max-height:88%;border-radius:12px;box-shadow:0 24px 60px -24px #000;background:#000}
|
||||||
|
.audiobox{display:flex;flex-direction:column;align-items:center;gap:16px;width:min(460px,86%)}
|
||||||
|
.audio-art{width:104px;height:104px;border-radius:22px;display:grid;place-items:center;color:#fff;
|
||||||
|
background:linear-gradient(140deg,var(--accent),var(--accent-deep));box-shadow:0 20px 46px -18px var(--accent)}
|
||||||
|
.audio-art svg{width:48px;height:48px}
|
||||||
|
.audio-name{font-size:14px;font-weight:600;color:var(--txt);text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 10px}
|
||||||
|
.audiobox audio{width:100%}
|
||||||
.assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)}
|
.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{display:flex;flex-direction:column;gap:3px}
|
||||||
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
|
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
|
||||||
@ -560,7 +570,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
display:flex;align-items:center;justify-content:center;gap:9px}
|
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}
|
.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}
|
.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}
|
.ldr{display:inline-block;box-sizing:border-box;vertical-align:middle;width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:0 0 auto}
|
||||||
|
|
||||||
/* diff view */
|
/* 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}
|
.diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0}
|
||||||
@ -599,10 +609,22 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
|
.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
|
||||||
.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)}
|
.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)}
|
||||||
.ce-btn svg{width:13px;height:13px}
|
.ce-btn svg{width:13px;height:13px}
|
||||||
|
/* spec editors (client / job / .p4ignore): a clean editor surface, not a flat grey box */
|
||||||
.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none;
|
.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none;
|
||||||
font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt);
|
font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt);
|
||||||
background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto}
|
background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto;
|
||||||
|
box-shadow:inset 3px 0 0 var(--border-soft)}
|
||||||
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
|
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
|
||||||
|
/* rich code editor: transparent textarea over a highlighted underlay + gutter */
|
||||||
|
.code.editing{overflow:hidden}
|
||||||
|
.code.editing .code-gutter{overflow:hidden}
|
||||||
|
.ce-wrap{position:relative;flex:1;min-width:0;background:transparent}
|
||||||
|
.ce-under,.ce-ta{margin:0;padding:10px 16px;border:0;font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;white-space:pre;word-wrap:normal;letter-spacing:normal}
|
||||||
|
.ce-under{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:none;color:var(--txt)}
|
||||||
|
.ce-under code{font-family:inherit;background:none;padding:0;white-space:pre}
|
||||||
|
.ce-ta{position:absolute;inset:0;width:100%;height:100%;resize:none;outline:none;overflow:auto;background:transparent;color:transparent;caret-color:var(--accent-2)}
|
||||||
|
.ce-ta::selection{background:rgba(124,110,246,.28);color:transparent}
|
||||||
|
.ce-ta::-moz-selection{background:rgba(124,110,246,.28);color:transparent}
|
||||||
|
|
||||||
/* highlight.js tokens mapped to the app palette (theme-aware) */
|
/* highlight.js tokens mapped to the app palette (theme-aware) */
|
||||||
.hljs{color:var(--txt);background:none}
|
.hljs{color:var(--txt);background:none}
|
||||||
@ -642,10 +664,29 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
|
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
|
||||||
.histsplit .histlist .crow-go{opacity:0}
|
.histsplit .histlist .crow-go{opacity:0}
|
||||||
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
||||||
|
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
|
||||||
|
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
||||||
|
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto}
|
||||||
|
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
|
||||||
|
.vtab:hover{color:var(--txt);background:var(--hover)}
|
||||||
|
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
|
||||||
|
.vtab-ic{display:grid;place-items:center;color:var(--accent-2)}
|
||||||
|
.vtab-ic svg{width:15px;height:15px}
|
||||||
|
.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)}
|
||||||
|
.vtab-x:hover{background:var(--del);color:#fff}
|
||||||
|
.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative}
|
||||||
|
.vpane{flex:1;min-width:0;min-height:0}
|
||||||
|
.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0}
|
||||||
|
.vpane.tree{flex-direction:column}
|
||||||
|
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
|
||||||
|
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||||
|
.ft-total b{color:var(--accent-2)}
|
||||||
|
.vpane.tree .exp-tree{flex:1;min-height:0}
|
||||||
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
.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}
|
.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}
|
.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)}
|
.hmeta{font-size:11px;color:var(--faint)}
|
||||||
|
.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||||
|
|
||||||
/* 3d viewer toolbar */
|
/* 3d viewer toolbar */
|
||||||
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
|
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
|
||||||
@ -768,8 +809,23 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)}
|
.netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)}
|
||||||
.netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer}
|
.netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer}
|
||||||
.netbar.warn:hover{background:rgba(232,176,75,.2)}
|
.netbar.warn:hover{background:rgba(232,176,75,.2)}
|
||||||
|
/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */
|
||||||
|
.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)}
|
||||||
|
/* "you're behind the server" — informational blue, clickable to Get Latest */
|
||||||
|
.netbar.behind{background:rgba(90,160,255,.14);color:#4f8fe0;border-bottom:1px solid rgba(90,160,255,.32);cursor:pointer}
|
||||||
|
.netbar.behind:hover{background:rgba(90,160,255,.2)}
|
||||||
|
.behind-names{font-weight:500;opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
|
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
|
||||||
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
|
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
|
||||||
|
.netbar-btn+.netbar-btn{margin-left:0}
|
||||||
|
.netbar-btn:hover{background:currentColor}
|
||||||
|
.netbar-btn:hover{color:var(--bg)}
|
||||||
|
/* reconcile offline-work dialog */
|
||||||
|
.rec-summary{display:flex;align-items:center;gap:6px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
|
||||||
|
.rec-all{margin-left:auto;color:var(--accent-2);font-weight:700;cursor:pointer}
|
||||||
|
.rec-all:hover{text-decoration:underline}
|
||||||
|
.rec-row{cursor:pointer}
|
||||||
|
.rec-row .chk{flex:0 0 16px}
|
||||||
/* "held by someone else" pill on a Changes row */
|
/* "held by someone else" pill on a Changes row */
|
||||||
.held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted);
|
.held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted);
|
||||||
background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden}
|
background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden}
|
||||||
@ -836,8 +892,37 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
|
|
||||||
/* ---------------- People & Roles ---------------- */
|
/* ---------------- People & Roles ---------------- */
|
||||||
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
|
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
|
||||||
|
.picker.wide.tall{height:660px}
|
||||||
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||||
.picker.wide .ph-sub{margin-right:auto}
|
.picker.wide .ph-sub{margin-right:auto}
|
||||||
|
/* ---- Depot & Workspace file/size explorer ---- */
|
||||||
|
.exp-tabs{display:flex;gap:4px;margin-left:14px;background:var(--sunk);padding:3px;border-radius:9px}
|
||||||
|
.exp-tab{border:none;background:none;color:var(--muted);font-size:12px;font-weight:600;padding:5px 14px;border-radius:7px;cursor:pointer;font-family:var(--font)}
|
||||||
|
.exp-tab:hover{color:var(--txt)}
|
||||||
|
.exp-tab.on{background:var(--accent);color:#fff}
|
||||||
|
.exp-total{display:flex;align-items:center;padding:10px 18px;border-bottom:1px solid var(--border-soft)}
|
||||||
|
.exp-total .n{font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.exp-total .sz{margin-left:auto;font-size:13px;font-weight:700;color:var(--accent-2);font-variant-numeric:tabular-nums;white-space:nowrap;padding-left:12px}
|
||||||
|
.exp-tree{flex:1;overflow-y:auto;padding:6px 8px 12px}
|
||||||
|
.exp-node{display:flex;flex-direction:column}
|
||||||
|
.exp-row{display:flex;align-items:center;gap:7px;padding:5px 10px;border-radius:8px;cursor:default;transition:background .1s}
|
||||||
|
.exp-row.dir{cursor:pointer}
|
||||||
|
.exp-row:hover{background:var(--hover)}
|
||||||
|
.exp-row:hover .exp-open{opacity:1}
|
||||||
|
.exp-caret{width:14px;flex:0 0 14px;display:grid;place-items:center;color:var(--faint)}
|
||||||
|
.chevi{display:grid;place-items:center;transition:transform .12s}
|
||||||
|
.chevi svg{width:13px;height:13px}
|
||||||
|
.chevi.open{transform:rotate(90deg)}
|
||||||
|
.exp-ic{width:17px;height:17px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
|
||||||
|
.exp-ic svg{width:16px;height:16px}
|
||||||
|
.exp-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:60px}
|
||||||
|
.exp-count{font-size:10.5px;color:var(--faint);white-space:nowrap;flex:0 0 auto}
|
||||||
|
.exp-bar{margin-left:auto;flex:0 0 110px;height:6px;border-radius:4px;background:var(--sunk);overflow:hidden}
|
||||||
|
.exp-bar > span{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:4px}
|
||||||
|
.exp-size{flex:0 0 68px;text-align:right;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
|
.exp-open{flex:0 0 auto;width:22px;height:22px;display:grid;place-items:center;color:var(--faint);cursor:pointer;opacity:0;border-radius:6px;transition:opacity .1s,color .1s}
|
||||||
|
.exp-open svg{width:14px;height:14px}
|
||||||
|
.exp-open:hover{color:var(--accent-2);background:var(--panel)}
|
||||||
.ur-body{flex:1;display:flex;min-height:0}
|
.ur-body{flex:1;display:flex;min-height:0}
|
||||||
.ur-list{flex:0 0 300px;display:flex;flex-direction:column;border-right:1px solid var(--border-soft);min-height:0}
|
.ur-list{flex:0 0 300px;display:flex;flex-direction:column;border-right:1px solid var(--border-soft);min-height:0}
|
||||||
.ur-filter{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border-soft)}
|
.ur-filter{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border-soft)}
|
||||||
|
|||||||
594
src/App.tsx
594
src/App.tsx
@ -3,14 +3,27 @@ import type { MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
|
||||||
import ModelViewer from "./ModelViewer";
|
import ModelViewer from "./ModelViewer";
|
||||||
import CodeView from "./CodeView";
|
import CodeView from "./CodeView";
|
||||||
import UpdateBanner from "./Updater";
|
import UpdateBanner from "./Updater";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev } from "./p4";
|
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4";
|
||||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||||
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
||||||
|
|
||||||
|
// native OS notification (works even when the window is hidden in the tray)
|
||||||
|
let notifyReady: boolean | null = null;
|
||||||
|
async function notify(title: string, body: string) {
|
||||||
|
try {
|
||||||
|
if (notifyReady === null) {
|
||||||
|
notifyReady = await isPermissionGranted();
|
||||||
|
if (!notifyReady) notifyReady = (await requestPermission()) === "granted";
|
||||||
|
}
|
||||||
|
if (notifyReady) sendNotification({ title, body });
|
||||||
|
} catch { /* notifications unavailable — the in-app toast still shows */ }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- window controls (frameless) ---------------- */
|
/* ---------------- window controls (frameless) ---------------- */
|
||||||
function WinControls() {
|
function WinControls() {
|
||||||
const w = getCurrentWindow();
|
const w = getCurrentWindow();
|
||||||
@ -302,6 +315,7 @@ type Tab = "changes" | "history";
|
|||||||
|
|
||||||
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
|
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
|
||||||
|
|
||||||
|
type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view area
|
||||||
type ModalState =
|
type ModalState =
|
||||||
| { kind: "workspaces"; items: Client[]; current: string }
|
| { kind: "workspaces"; items: Client[]; current: string }
|
||||||
| { kind: "scope"; path: string; dirs: Dir[] }
|
| { kind: "scope"; path: string; dirs: Dir[] }
|
||||||
@ -315,6 +329,8 @@ type ModalState =
|
|||||||
| { kind: "streams" }
|
| { kind: "streams" }
|
||||||
| { kind: "jobs" }
|
| { kind: "jobs" }
|
||||||
| { kind: "clean" }
|
| { kind: "clean" }
|
||||||
|
| { kind: "reconcile" }
|
||||||
|
| { kind: "explorer" }
|
||||||
| { kind: "ignore" }
|
| { kind: "ignore" }
|
||||||
| { kind: "filelog"; depot: string; name: string }
|
| { kind: "filelog"; depot: string; name: string }
|
||||||
| { kind: "client"; name: string; isNew: boolean }
|
| { kind: "client"; name: string; isNew: boolean }
|
||||||
@ -349,6 +365,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
||||||
const [history, setHistory] = useState<Change[]>([]);
|
const [history, setHistory] = useState<Change[]>([]);
|
||||||
|
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
|
||||||
const [detail, setDetail] = useState<Describe | null>(null);
|
const [detail, setDetail] = useState<Describe | null>(null);
|
||||||
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
|
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
|
||||||
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
|
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
|
||||||
@ -366,6 +383,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
||||||
const [resolveOpen, setResolveOpen] = useState(false);
|
const [resolveOpen, setResolveOpen] = useState(false);
|
||||||
const [offline, setOffline] = useState(false); // lost connection to the server
|
const [offline, setOffline] = useState(false); // lost connection to the server
|
||||||
|
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
|
||||||
|
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
|
||||||
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
||||||
const notifPrimed = useRef(false); // don't toast on the very first poll
|
const notifPrimed = useRef(false); // don't toast on the very first poll
|
||||||
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
|
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
|
||||||
@ -380,6 +399,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
// bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
|
// bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
|
||||||
const [dockOpen, setDockOpen] = useState(false);
|
const [dockOpen, setDockOpen] = useState(false);
|
||||||
const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
|
const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
|
||||||
|
// right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab.
|
||||||
|
const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => {
|
||||||
|
try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {}
|
||||||
|
return ["viewer"];
|
||||||
|
});
|
||||||
|
const [viewTab, setViewTab] = useState<ViewTab>("viewer");
|
||||||
|
const [treeSide, setTreeSide] = useState<"depot" | "workspace">("depot"); // which side the embedded File Tree shows
|
||||||
|
const [treeNonce, setTreeNonce] = useState(0); // bump to force the tree to (re)focus a side
|
||||||
const [dockH, setDockH] = useState<number>(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height
|
const [dockH, setDockH] = useState<number>(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height
|
||||||
useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]);
|
useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]);
|
||||||
const [termLines, setTermLines] = useState<TermLine[]>([]); // terminal output blocks
|
const [termLines, setTermLines] = useState<TermLine[]>([]); // terminal output blocks
|
||||||
@ -644,6 +671,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
|
if (workOffline) return; // explicit offline-work mode: don't poll the server
|
||||||
try {
|
try {
|
||||||
const latest = await p4.latestChange(activePath);
|
const latest = await p4.latestChange(activePath);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
@ -653,11 +681,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; }
|
if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; }
|
||||||
else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) {
|
else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) {
|
||||||
const who = latest?.user || "someone";
|
const who = latest?.user || "someone";
|
||||||
if ((who || "").toLowerCase() !== (info?.userName || "").toLowerCase()) {
|
const mine = (who || "").toLowerCase() === (info?.userName || "").toLowerCase();
|
||||||
|
if (!mine) {
|
||||||
|
const desc = (latest?.desc || "").trim().replace(/\s+/g, " ").slice(0, 160) || t("(no description)");
|
||||||
flash(t("{user} submitted #{n}", { user: who, n }));
|
flash(t("{user} submitted #{n}", { user: who, n }));
|
||||||
|
notify(t("{user} submitted #{n}", { user: who, n }), desc); // native toast — arrives even from the tray
|
||||||
}
|
}
|
||||||
lastSubmit.current = n;
|
lastSubmit.current = n;
|
||||||
if (tab === "history") loadHistory();
|
if (tab === "history") loadHistory();
|
||||||
|
checkBehind(); // a new submit landed → am I now behind?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -668,7 +700,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
tick();
|
tick();
|
||||||
return () => { alive = false; clearInterval(id); };
|
return () => { alive = false; clearInterval(id); };
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [activePath, offline, tab]);
|
}, [activePath, offline, tab, workOffline]);
|
||||||
|
|
||||||
|
// remember which view-panel tabs are open across restarts
|
||||||
|
useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);
|
||||||
|
|
||||||
|
// check "am I behind the server" when connected or the working folder changes
|
||||||
|
useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);
|
||||||
|
|
||||||
|
// window was closed to the tray → tell the user once per session it's still alive
|
||||||
|
useEffect(() => {
|
||||||
|
let shown = false;
|
||||||
|
const un = listen("tray-hidden", () => {
|
||||||
|
if (shown) return; shown = true;
|
||||||
|
notify(t("Exbyte Depot is still running"), t("It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit."));
|
||||||
|
});
|
||||||
|
return () => { un.then((f) => f()); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// drag & drop files/folders onto the window → reconcile them into the changelist
|
// drag & drop files/folders onto the window → reconcile them into the changelist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -762,8 +810,20 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
const f = scope !== undefined ? scope : activePath;
|
const f = scope !== undefined ? scope : activePath;
|
||||||
const path = f ? f + "/..." : "";
|
const path = f ? f + "/..." : "";
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { setHistory(await p4.history(path)); }
|
try {
|
||||||
catch (e) { flash(String(e), true); } finally { setBusy(false); }
|
const h = await p4.history(path);
|
||||||
|
setHistory(h);
|
||||||
|
// commit weights load in the background so the list shows instantly
|
||||||
|
const nums = h.map((c) => c.change || "").filter(Boolean);
|
||||||
|
setChangeSizes({});
|
||||||
|
if (nums.length) {
|
||||||
|
p4.changeSizes(nums, f).then((rows) => {
|
||||||
|
const m: Record<string, number> = {};
|
||||||
|
for (const r of rows) m[r.change] = r.size;
|
||||||
|
setChangeSizes(m);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) { flash(String(e), true); } finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
function switchTab(t: Tab) {
|
function switchTab(t: Tab) {
|
||||||
setTab(t);
|
setTab(t);
|
||||||
@ -773,9 +833,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
|
|
||||||
async function getLatest() {
|
async function getLatest() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too
|
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
|
||||||
catch (e) { flash(String(e), true); setBusy(false); }
|
catch (e) { flash(String(e), true); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
// check whether the server has newer content than my workspace (files behind)
|
||||||
|
const behindRef = useRef(0);
|
||||||
|
async function checkBehind() {
|
||||||
|
if (workOffline) return;
|
||||||
|
try {
|
||||||
|
const b = await p4.behind(activePath);
|
||||||
|
const was = behindRef.current;
|
||||||
|
behindRef.current = b.count;
|
||||||
|
setBehind(b.count > 0 ? b : null);
|
||||||
|
// notify only when we *newly* fall behind (not on every recheck)
|
||||||
|
if (b.count > 0 && was === 0) {
|
||||||
|
notify(t("Your workspace is behind"), t("{n} file(s) on the server are newer than your copy. Get Latest to update.", { n: b.count }));
|
||||||
|
}
|
||||||
|
} catch { /* ignore — non-critical */ }
|
||||||
|
}
|
||||||
// sync the workspace to an exact changelist / label / date (Get Revision)
|
// sync the workspace to an exact changelist / label / date (Get Revision)
|
||||||
async function syncTo(rev: string) {
|
async function syncTo(rev: string) {
|
||||||
const r = rev.trim();
|
const r = rev.trim();
|
||||||
@ -1041,6 +1116,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
|
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
|
||||||
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
|
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
|
||||||
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
|
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
|
||||||
|
...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []),
|
||||||
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })),
|
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })),
|
||||||
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
|
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
|
||||||
...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
||||||
@ -1064,6 +1140,44 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); }
|
try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); }
|
||||||
catch (e) { flash(String(e), true); }
|
catch (e) { flash(String(e), true); }
|
||||||
}
|
}
|
||||||
|
// toggle explicit "Work Offline" mode (P4V-style): stop polling the server;
|
||||||
|
// edits stay local until reconciled.
|
||||||
|
function toggleWorkOffline() {
|
||||||
|
setWorkOffline((v) => {
|
||||||
|
const nv = !v;
|
||||||
|
try { localStorage.setItem("exd-workoffline", nv ? "1" : "0"); } catch {}
|
||||||
|
if (nv) { setOffline(false); flash(t("Working offline — edits stay local. Run Reconcile Offline Work to sync.")); }
|
||||||
|
else { flash(t("Back online.")); refresh(); }
|
||||||
|
return nv;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// right-panel view tabs: open/focus, close, and Window-menu toggle
|
||||||
|
function focusViewTab(k: ViewTab) { setViewTabs((ts) => (ts.includes(k) ? ts : [...ts, k])); setViewTab(k); }
|
||||||
|
function closeViewTab(k: ViewTab) {
|
||||||
|
setViewTabs((ts) => { const nt = ts.filter((x) => x !== k); if (viewTab === k) setViewTab((nt[nt.length - 1] as ViewTab) || "viewer"); return nt; });
|
||||||
|
}
|
||||||
|
function toggleViewTab(k: ViewTab) { if (viewTabs.includes(k)) closeViewTab(k); else focusViewTab(k); }
|
||||||
|
// open the File Tree view-tab focused on a given side (Depot or Workspace)
|
||||||
|
function showInTree(side: "depot" | "workspace") { setTreeSide(side); setTreeNonce((n) => n + 1); focusViewTab("tree"); }
|
||||||
|
// open a file picked in the File Tree: depot files preview in the File Viewer,
|
||||||
|
// local files open in the OS file browser.
|
||||||
|
function openTreeFile(n: FsEntry, side: "depot" | "workspace") {
|
||||||
|
if (side === "depot") {
|
||||||
|
if (tab !== "changes") switchTab("changes");
|
||||||
|
setPreviewCL({ depotFile: n.path, _spec: n.path, action: "browse", rev: "", type: "", change: "" } as unknown as OpenedFile);
|
||||||
|
focusViewTab("viewer");
|
||||||
|
} else {
|
||||||
|
reveal(n.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// clear the read-only bit on selected local files so they can be edited while
|
||||||
|
// offline (before a checkout). Reconcile picks the changes up later.
|
||||||
|
async function makeWritable(targets: OpenedFile[]) {
|
||||||
|
const locals = targets.map((f) => f.clientFile || "").filter(Boolean) as string[];
|
||||||
|
if (!locals.length) { flash(t("No local path for the selection."), true); return; }
|
||||||
|
try { await p4.setWritable(locals, true); flash(t("{n} file(s) made writable for offline editing.", { n: locals.length })); }
|
||||||
|
catch (e) { flash(String(e), true); }
|
||||||
|
}
|
||||||
// diff a historical revision against the one before it (History preview)
|
// diff a historical revision against the one before it (History preview)
|
||||||
async function showDiffPrev(file: OpenedFile) {
|
async function showDiffPrev(file: OpenedFile) {
|
||||||
const dp = file.depotFile || "";
|
const dp = file.depotFile || "";
|
||||||
@ -1086,18 +1200,53 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
} catch (e) { flash(String(e), true); }
|
} catch (e) { flash(String(e), true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => void }[]> = {
|
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => void }[]> = {
|
||||||
File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }],
|
File: [
|
||||||
Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }],
|
{ label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
|
||||||
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Clean workspace…"), icon: I.revert, act: () => setModal({ kind: "clean" }) }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }],
|
{ label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
|
||||||
Window: [
|
...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []),
|
||||||
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") },
|
{ label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
|
||||||
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") },
|
{ label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
|
||||||
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
|
|
||||||
{ label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
|
|
||||||
],
|
],
|
||||||
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, act: () => setModal({ kind: "ignore" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
|
Connection: [
|
||||||
Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }],
|
{ label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
|
||||||
|
{ label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
|
||||||
|
],
|
||||||
|
Actions: [
|
||||||
|
{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, hint: t("Scan the workspace for files changed outside the app (reconcile)."), act: rescan },
|
||||||
|
{ label: (workOffline ? "✓ " : "") + t("Work Offline"), icon: I.power, hint: t("Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V."), act: toggleWorkOffline },
|
||||||
|
{ label: t("Reconcile Offline Work…"), icon: I.scan, hint: t("Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them."), act: () => setModal({ kind: "reconcile" }) },
|
||||||
|
{ label: t("Clean workspace…"), icon: I.revert, hint: t("Make the workspace exactly match the depot — discards un-opened local changes."), act: () => setModal({ kind: "clean" }) },
|
||||||
|
{ label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, hint: t("Sync the workspace to the newest revision (pull)."), act: getLatest },
|
||||||
|
{ label: t("Get Revision…"), icon: I.clock, hint: t("Sync the workspace to a specific changelist, label or date."), act: promptSyncTo },
|
||||||
|
{ label: t("Commit (local)"), icon: I.check, hint: t("Save selected files into a local pending changelist (not sent yet)."), act: doCommit },
|
||||||
|
{ label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, hint: t("Send pending changelists to the server (push)."), act: pushAll },
|
||||||
|
...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, hint: t("Resolve merge conflicts left after a sync."), act: () => setResolveOpen(true) }] : []),
|
||||||
|
{ label: t("Revert All…"), icon: I.revert, hint: t("Discard every open change and restore depot versions."), act: revertAll },
|
||||||
|
{ label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
|
||||||
|
],
|
||||||
|
Window: [
|
||||||
|
{ label: (viewTabs.includes("viewer") ? "✓ " : "") + t("File Viewer"), icon: FILE_GLYPH, hint: t("The preview panel for the selected file (image, 3D model, code, diff)."), act: () => toggleViewTab("viewer") },
|
||||||
|
{ label: (viewTabs.includes("tree") ? "✓ " : "") + t("File Tree"), icon: I.folder, hint: t("A folder hierarchy of the Depot and your Workspace, docked into the view panel."), act: () => toggleViewTab("tree") },
|
||||||
|
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
|
||||||
|
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
|
||||||
|
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
|
||||||
|
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
|
||||||
|
],
|
||||||
|
Tools: [
|
||||||
|
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
|
||||||
|
{ label: t("Files & Sizes…"), icon: I.folder, hint: t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs."), act: () => setModal({ kind: "explorer" }) },
|
||||||
|
{ label: t("Exclusive Locks (typemap)…"), icon: I.lock, hint: t("Set which binary asset types are exclusive-checkout (+l) so only one person edits them."), act: () => setModal({ kind: "typemap" }) },
|
||||||
|
{ label: t("Labels…"), icon: I.clock, hint: t("Named snapshots of file revisions: tag files, or sync to a label."), act: () => setModal({ kind: "labels" }) },
|
||||||
|
{ label: t("Integrate / Merge / Copy…"), icon: I.branch, hint: t("Move changes between branches of the depot."), act: () => setModal({ kind: "branch" }) },
|
||||||
|
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
|
||||||
|
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
|
||||||
|
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
|
||||||
|
{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the Visual Studio solution found in the working folder."), act: startBuild },
|
||||||
|
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
|
||||||
|
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
|
||||||
|
],
|
||||||
|
Help: [{ label: t("About"), icon: I.info, hint: t("Version and information about Exbyte Depot."), act: () => setModal({ kind: "about" }) }],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1110,7 +1259,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{t(name)}
|
{t(name)}
|
||||||
<div className="dropdown">
|
<div className="dropdown">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
|
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()} title={it.hint}>
|
||||||
{it.icon && <span className="di-ic">{it.icon}</span>}
|
{it.icon && <span className="di-ic">{it.icon}</span>}
|
||||||
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
|
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
|
||||||
</div>
|
</div>
|
||||||
@ -1128,6 +1277,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
|
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
|
||||||
onContextMenu={(e) => openCtx(e, [
|
onContextMenu={(e) => openCtx(e, [
|
||||||
|
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") },
|
||||||
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
|
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
|
||||||
{ label: t("Switch workspace…"), act: openWorkspaces },
|
{ label: t("Switch workspace…"), act: openWorkspaces },
|
||||||
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
|
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
|
||||||
@ -1140,6 +1290,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
|
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
|
||||||
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
||||||
onContextMenu={(e) => openCtx(e, [
|
onContextMenu={(e) => openCtx(e, [
|
||||||
|
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
|
||||||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||||||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
|
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
|
||||||
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
||||||
@ -1164,9 +1315,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{offline && (
|
{workOffline ? (
|
||||||
|
<div className="netbar offmode">
|
||||||
|
{I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
|
||||||
|
<button className="netbar-btn" onClick={() => setModal({ kind: "reconcile" })}>{t("Reconcile…")}</button>
|
||||||
|
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Go online")}</button>
|
||||||
|
</div>
|
||||||
|
) : offline && (
|
||||||
<div className="netbar off">
|
<div className="netbar off">
|
||||||
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
|
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
|
||||||
|
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{needResolve.length > 0 && tab === "changes" && (
|
{needResolve.length > 0 && tab === "changes" && (
|
||||||
@ -1175,6 +1333,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<button className="netbar-btn">{t("Resolve…")}</button>
|
<button className="netbar-btn">{t("Resolve…")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{behind && behind.count > 0 && !workOffline && (
|
||||||
|
<div className="netbar behind" onClick={getLatest} title={behind.sample.join(", ")}>
|
||||||
|
{I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })}
|
||||||
|
{behind.sample.length > 0 ? <span className="behind-names">{behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""}</span> : null}
|
||||||
|
<button className="netbar-btn">{t("Get Latest")}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
|
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
|
||||||
<div className="left">
|
<div className="left">
|
||||||
@ -1281,7 +1446,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
|
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
|
||||||
onContext={(c, e) => openCtx(e, [
|
onContext={(c, e) => openCtx(e, [
|
||||||
{ label: t("Open this changelist"), act: () => openChange(c) },
|
{ label: t("Open this changelist"), act: () => openChange(c) },
|
||||||
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
|
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
|
||||||
@ -1292,28 +1457,58 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "changes" ? (
|
<div className="viewpanel">
|
||||||
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
|
{viewTabs.length > 1 && (
|
||||||
) : (
|
<div className="vtabs">
|
||||||
<div className={"histsplit" + (histFile ? "" : " solo")}>
|
{viewTabs.map((k) => (
|
||||||
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
|
<div key={k} className={"vtab" + (viewTab === k ? " on" : "")} onClick={() => setViewTab(k)}>
|
||||||
{histFile && (<>
|
<span className="vtab-ic">{k === "viewer" ? FILE_GLYPH : k === "tree" ? I.folder : I.lock}</span>
|
||||||
<div className="vhandle histh" title={t("Drag to resize")}
|
{k === "viewer" ? t("File Viewer") : k === "tree" ? t("File Tree") : t("File Locks")}
|
||||||
onPointerDown={(e) => {
|
<span className="vtab-x" onClick={(e) => { e.stopPropagation(); closeViewTab(k); }} title={t("Close")}>×</span>
|
||||||
e.preventDefault();
|
</div>
|
||||||
const startX = e.clientX, start = histW;
|
))}
|
||||||
document.body.classList.add("resizing");
|
</div>
|
||||||
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
|
)}
|
||||||
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
|
<div className="viewbody">
|
||||||
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
|
{viewTabs.length === 0 && <div className="empty">{I.folder}{t("No panel open.\nAdd File Viewer or File Tree from the Window menu.")}</div>}
|
||||||
// mouse fallback in case pointer events don't fire in the webview
|
{viewTabs.includes("viewer") && (
|
||||||
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
|
<div className="vpane" style={{ display: viewTab === "viewer" ? "flex" : "none" }}>
|
||||||
window.addEventListener("blur", up); // released outside the window → end drag
|
{tab === "changes" ? (
|
||||||
}} />
|
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
|
||||||
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
|
) : (
|
||||||
</>)}
|
<div className={"histsplit" + (histFile ? "" : " solo")}>
|
||||||
|
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
|
||||||
|
{histFile && (<>
|
||||||
|
<div className="vhandle histh" title={t("Drag to resize")}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX, start = histW;
|
||||||
|
document.body.classList.add("resizing");
|
||||||
|
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
|
||||||
|
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
|
||||||
|
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
|
||||||
|
// mouse fallback in case pointer events don't fire in the webview
|
||||||
|
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
|
||||||
|
window.addEventListener("blur", up); // released outside the window → end drag
|
||||||
|
}} />
|
||||||
|
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewTabs.includes("tree") && (
|
||||||
|
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
|
||||||
|
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} initialSide={treeSide} sideNonce={treeNonce} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewTabs.includes("locks") && (
|
||||||
|
<div className="vpane tree" style={{ display: viewTab === "locks" ? "flex" : "none" }}>
|
||||||
|
<LocksPanel me={info?.userName || ""} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
|
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
|
||||||
@ -1359,6 +1554,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
|
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
|
||||||
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
|
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||||
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||||
|
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||||
|
{modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
|
||||||
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
|
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||||
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
||||||
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
|
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
|
||||||
@ -1996,48 +2193,56 @@ function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
|
/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
|
||||||
function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
|
// the File Locks list (bar + rows), reused by the modal AND the dockable panel
|
||||||
|
function LocksPanel({ me, onReveal, onFlash, onUnlock }: { me: string; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
|
||||||
const [files, setFiles] = useState<OpenedFile[]>([]);
|
const [files, setFiles] = useState<OpenedFile[]>([]);
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [lockedOnly, setLockedOnly] = useState(true);
|
const [lockedOnly, setLockedOnly] = useState(true);
|
||||||
const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); };
|
const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); };
|
||||||
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
|
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||||
const isLocked = (f: OpenedFile) => (f as Record<string, unknown>).ourLock !== undefined;
|
const isLocked = (f: OpenedFile) => (f as Record<string, unknown>).ourLock !== undefined;
|
||||||
const s = q.trim().toLowerCase();
|
const s = q.trim().toLowerCase();
|
||||||
const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
|
const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
|
||||||
const lockedCount = files.filter(isLocked).length;
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rslv-bar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
|
||||||
|
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||||
|
<button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
|
||||||
|
<button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</button>
|
||||||
|
</div>
|
||||||
|
<div className="srch-list">
|
||||||
|
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
|
||||||
|
{!busy && rows.length === 0 && <div className="ur-empty">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
|
||||||
|
{rows.map((f) => {
|
||||||
|
const dp = f.depotFile || "";
|
||||||
|
const sp = splitPath(dp);
|
||||||
|
const locked = isLocked(f);
|
||||||
|
const mine = (f.user || "").toLowerCase() === me.toLowerCase();
|
||||||
|
return (
|
||||||
|
<div key={dp + (f.client || "")} className="srch-row">
|
||||||
|
<span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
|
||||||
|
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
||||||
|
<span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
|
||||||
|
{locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
|
||||||
|
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
|
||||||
|
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
|
||||||
return (
|
return (
|
||||||
<div className="modal-back" onClick={onClose}>
|
<div className="modal-back" onClick={onClose}>
|
||||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
|
<div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
|
||||||
<span className="ph-sub">{t("{n} open · {m} locked", { n: files.length, m: lockedCount })}</span>
|
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||||
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
|
||||||
</div>
|
|
||||||
<div className="rslv-bar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
|
|
||||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
|
||||||
<button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
|
|
||||||
<button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</button>
|
|
||||||
</div>
|
|
||||||
<div className="srch-list">
|
|
||||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
|
|
||||||
{!busy && rows.length === 0 && <div className="ur-empty">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
|
|
||||||
{rows.map((f) => {
|
|
||||||
const dp = f.depotFile || "";
|
|
||||||
const sp = splitPath(dp);
|
|
||||||
const locked = isLocked(f);
|
|
||||||
const mine = (f.user || "").toLowerCase() === me.toLowerCase();
|
|
||||||
return (
|
|
||||||
<div key={dp + (f.client || "")} className="srch-row">
|
|
||||||
<span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
|
|
||||||
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
|
||||||
<span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
|
|
||||||
{locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
|
|
||||||
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
<LocksPanel me={me} onReveal={onReveal} onFlash={onFlash} onUnlock={onUnlock} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -2131,6 +2336,177 @@ function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- reconcile offline work ---------------- */
|
||||||
|
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
|
||||||
|
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
|
||||||
|
function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
|
||||||
|
const [files, setFiles] = useState<OpenedFile[]>([]);
|
||||||
|
const [busy, setBusy] = useState(true);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
|
const keyOf = (f: OpenedFile) => f.depotFile || f.clientFile || "";
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
p4.reconcilePreview(scope)
|
||||||
|
.then((f) => { if (live) { setFiles(f); setSel(new Set(f.map(keyOf).filter(Boolean))); } })
|
||||||
|
.catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } })
|
||||||
|
.finally(() => live && setBusy(false));
|
||||||
|
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", k);
|
||||||
|
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||||
|
}, [scope]); // eslint-disable-line
|
||||||
|
const toggle = (k: string) => setSel((s) => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });
|
||||||
|
const allOn = files.length > 0 && sel.size === files.length;
|
||||||
|
const toggleAll = () => setSel(allOn ? new Set() : new Set(files.map(keyOf).filter(Boolean)));
|
||||||
|
async function apply() {
|
||||||
|
const paths = files.map(keyOf).filter((k) => sel.has(k));
|
||||||
|
if (!paths.length) return;
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const opened = await p4.reconcileApply(paths);
|
||||||
|
onFlash(t("Reconciled {n} file(s) into a changelist.", { n: opened.length || paths.length }));
|
||||||
|
onDone();
|
||||||
|
} catch (e) { onFlash(String(e), true); }
|
||||||
|
finally { setApplying(false); }
|
||||||
|
}
|
||||||
|
// group for a readable header count
|
||||||
|
const counts = { add: 0, edit: 0, delete: 0 } as Record<string, number>;
|
||||||
|
for (const f of files) { const a = (f.action || "").toLowerCase(); if (a.includes("add")) counts.add++; else if (a.includes("delete")) counts.delete++; else counts.edit++; }
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="picker-head">{I.scan}<h3>{t("Reconcile Offline Work")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
|
||||||
|
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||||
|
</div>
|
||||||
|
<div className="tm-hint">{t("These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.")}</div>
|
||||||
|
{!busy && files.length > 0 && (
|
||||||
|
<div className="rec-summary">
|
||||||
|
<span style={{ color: "var(--add)", fontWeight: 700 }}>+{counts.add}</span> {t("added")} · <span style={{ color: "var(--edit)", fontWeight: 700 }}>±{counts.edit}</span> {t("modified")} · <span style={{ color: "var(--del)", fontWeight: 700 }}>−{counts.delete}</span> {t("deleted")}
|
||||||
|
<span className="rec-all" onClick={toggleAll}>{allOn ? t("Deselect all") : t("Select all")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="srch-list">
|
||||||
|
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning workspace for offline changes…")}</div>}
|
||||||
|
{!busy && files.length === 0 && <div className="ur-empty">{t("Nothing to reconcile — the workspace matches what the server knows.")}</div>}
|
||||||
|
{files.map((f) => {
|
||||||
|
const dp = keyOf(f);
|
||||||
|
const sp = splitPath(dp);
|
||||||
|
const st = statusOf(f.action);
|
||||||
|
const on = sel.has(dp);
|
||||||
|
return (
|
||||||
|
<div key={dp} className="srch-row rec-row" onClick={() => toggle(dp)}>
|
||||||
|
<span className={"chk" + (on ? "" : " off")}>{on ? I.check : null}</span>
|
||||||
|
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
|
||||||
|
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
||||||
|
<span className="lk-who">{f.action}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||||
|
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
|
||||||
|
<button className="mbtn" onClick={apply} disabled={applying || busy || sel.size === 0}>{applying ? <span className="ldr sm" /> : null}{t("Reconcile {n} file(s)", { n: sel.size })}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Depot & Workspace file/size explorer ---------------- */
|
||||||
|
const FILE_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.5" /><path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.5" /></svg>;
|
||||||
|
const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) || Number(y.isDir) - Number(x.isDir) || x.name.localeCompare(y.name));
|
||||||
|
|
||||||
|
// one row in the tree; folders lazily load their children on first expand,
|
||||||
|
// files trigger onOpen (open in the File Viewer / reveal).
|
||||||
|
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function activate() {
|
||||||
|
if (!node.isDir) { onOpen?.(node); return; }
|
||||||
|
if (!open && kids === null) {
|
||||||
|
setBusy(true);
|
||||||
|
try { setKids(sortBySize(await load(node.path))); }
|
||||||
|
catch (e) { onFlash(String(e), true); setKids([]); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
const pct = Math.max(2, Math.round((node.size / max) * 100));
|
||||||
|
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
|
||||||
|
return (
|
||||||
|
<div className="exp-node">
|
||||||
|
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} title={node.path}>
|
||||||
|
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
|
||||||
|
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
|
||||||
|
<span className="exp-name">{node.name}</span>
|
||||||
|
{node.isDir && node.fileCount > 0 ? <span className="exp-count">{t("{n} files", { n: node.fileCount })}</span> : null}
|
||||||
|
<span className="exp-bar"><span style={{ width: pct + "%" }} /></span>
|
||||||
|
<span className="exp-size">{humanSize(node.size)}</span>
|
||||||
|
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
|
||||||
|
</div>
|
||||||
|
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
|
||||||
|
// Files & Sizes modal AND the embedded File Tree view-panel.
|
||||||
|
function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
|
||||||
|
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
|
||||||
|
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
|
||||||
|
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(true);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const load = (path: string) => (side === "depot" ? p4.depotLs(path) : p4.fsLs(path));
|
||||||
|
const rootPath = side === "depot" ? scope : "";
|
||||||
|
const rootLabel = side === "depot" ? (scope || t("Whole depot")) : t("Workspace root");
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setBusy(true); setKids(null); setErr("");
|
||||||
|
load(rootPath).then((k) => { if (live) setKids(sortBySize(k)); }).catch((e) => { if (live) { setErr(String(e)); setKids([]); } }).finally(() => live && setBusy(false));
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [side, scope]); // eslint-disable-line
|
||||||
|
const total = (kids || []).reduce((a, b) => a + b.size, 0);
|
||||||
|
const max = Math.max(1, ...(kids || []).map((k) => k.size));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ft-bar">
|
||||||
|
<div className="exp-tabs">
|
||||||
|
<button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
|
||||||
|
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
|
||||||
|
</div>
|
||||||
|
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
|
||||||
|
</div>
|
||||||
|
<div className="exp-tree">
|
||||||
|
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
|
||||||
|
{!busy && err && <div className="ur-empty">{err}</div>}
|
||||||
|
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
|
||||||
|
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} />)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", k);
|
||||||
|
return () => document.removeEventListener("keydown", k);
|
||||||
|
}, []); // eslint-disable-line
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
|
||||||
|
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||||
|
</div>
|
||||||
|
<div className="tm-hint">{t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.")}</div>
|
||||||
|
<FileTree scope={scope} onReveal={onReveal} onFlash={onFlash} onOpenFile={(n) => onReveal(n.path)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- file history timeline (filelog) ---------------- */
|
/* ---------------- file history timeline (filelog) ---------------- */
|
||||||
function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) {
|
function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) {
|
||||||
const [revs, setRevs] = useState<FileRev[]>([]);
|
const [revs, setRevs] = useState<FileRev[]>([]);
|
||||||
@ -2634,20 +3010,23 @@ function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- history list ---------------- */
|
/* ---------------- history list ---------------- */
|
||||||
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
|
function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { history: Change[]; sizes?: Record<string, number>; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
|
||||||
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
|
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
|
||||||
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
|
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
|
||||||
return (
|
return (
|
||||||
<div className="files" style={{ position: "relative" }}>
|
<div className="files" style={{ position: "relative" }}>
|
||||||
{history.map((c) => (
|
{history.map((c) => {
|
||||||
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
|
const sz = sizes?.[c.change || ""];
|
||||||
<Avatar user={c.user || "?"} size={26} />
|
return (
|
||||||
<span className="hbody">
|
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
|
||||||
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
|
<Avatar user={c.user || "?"} size={26} />
|
||||||
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}</span>
|
<span className="hbody">
|
||||||
</span>
|
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
|
||||||
</div>
|
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2803,8 +3182,10 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif
|
|||||||
|
|
||||||
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
|
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
|
||||||
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
|
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
|
||||||
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
|
: kind === "audio" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={false} />
|
||||||
: <CodeView file={file} onFlash={onFlash} />}
|
: kind === "video" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={true} />
|
||||||
|
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
|
||||||
|
: <CodeView file={file} onFlash={onFlash} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2844,6 +3225,55 @@ function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- audio / video preview with playback (like Anchorpoint) ---------------- */
|
||||||
|
const MEDIA_MIME: Record<string, string> = {
|
||||||
|
wav: "audio/wav", mp3: "audio/mpeg", ogg: "audio/ogg", oga: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", opus: "audio/opus",
|
||||||
|
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", ogv: "video/ogg",
|
||||||
|
};
|
||||||
|
function MediaViewer({ depot, name, spec, video }: { depot: string; name: string; spec?: string; video: boolean }) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [dur, setDur] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let u = ""; let live = true;
|
||||||
|
setUrl(null); setErr(""); setDur("");
|
||||||
|
(spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
|
||||||
|
if (!live) return;
|
||||||
|
const ext = (name.split(".").pop() || "").toLowerCase();
|
||||||
|
u = URL.createObjectURL(new Blob([buf], { type: MEDIA_MIME[ext] || (video ? "video/mp4" : "audio/mpeg") }));
|
||||||
|
setUrl(u);
|
||||||
|
}).catch((e) => live && setErr(String(e)));
|
||||||
|
return () => { live = false; if (u) URL.revokeObjectURL(u); };
|
||||||
|
}, [depot, name, spec, video]);
|
||||||
|
const onMeta = (e: { currentTarget: HTMLMediaElement }) => {
|
||||||
|
const d = e.currentTarget.duration;
|
||||||
|
if (isFinite(d)) { const m = Math.floor(d / 60), s = Math.round(d % 60); setDur(`${m}:${s.toString().padStart(2, "0")}`); }
|
||||||
|
};
|
||||||
|
const ext = (name.split(".").pop() || "").toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className="asset">
|
||||||
|
<div className="stage">
|
||||||
|
<span className="lbl2">{video ? I.hex : SOUND_GLYPH}{video ? t("Video") : t("Audio")}</span>
|
||||||
|
{err ? <div className="viewer-msg err">{err}</div>
|
||||||
|
: !url ? <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>
|
||||||
|
: video
|
||||||
|
? <video className="mediaplayer vid" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
|
||||||
|
: (
|
||||||
|
<div className="audiobox">
|
||||||
|
<div className="audio-art">{SOUND_GLYPH}</div>
|
||||||
|
<div className="audio-name">{name}</div>
|
||||||
|
<audio className="mediaplayer" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="assetmeta">
|
||||||
|
<Mi k={t("File")} v={name} /><Mi k={t("Duration")} v={dur || "—"} /><Mi k={t("Format")} v={ext} /><Mi k={t("Source")} v={spec ? t("server revision") : t("working copy")} acc />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const SOUND_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M11 5 6 9H3v6h3l5 4V5Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" /><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>;
|
||||||
|
|
||||||
/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
|
/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
|
||||||
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
|
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
|
||||||
const dp = file.depotFile || "";
|
const dp = file.depotFile || "";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import hljs from "highlight.js/lib/core";
|
import hljs from "highlight.js/lib/core";
|
||||||
import cpp from "highlight.js/lib/languages/cpp";
|
import cpp from "highlight.js/lib/languages/cpp";
|
||||||
import csharp from "highlight.js/lib/languages/csharp";
|
import csharp from "highlight.js/lib/languages/csharp";
|
||||||
@ -104,32 +104,53 @@ export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?
|
|||||||
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
|
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
|
||||||
catch { return esc(code); }
|
catch { return esc(code); }
|
||||||
}, [code, lang]);
|
}, [code, lang]);
|
||||||
|
// live-highlighted draft for the editor underlay (same highlighter as the preview)
|
||||||
|
const draftHi = useMemo(() => {
|
||||||
|
try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); }
|
||||||
|
catch { return esc(draft); }
|
||||||
|
}, [draft, lang]);
|
||||||
|
const preRef = useRef<HTMLPreElement>(null);
|
||||||
|
const gutRef = useRef<HTMLDivElement>(null);
|
||||||
|
const draftRows = draft.split("\n").length;
|
||||||
|
|
||||||
// editing mode — plain textarea over the working copy
|
// editing mode — a real code editor: transparent textarea over a syntax-
|
||||||
|
// highlighted underlay, with a line-number gutter (looks like the preview).
|
||||||
if (editing) {
|
if (editing) {
|
||||||
|
const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => {
|
||||||
|
const ta = e.currentTarget;
|
||||||
|
if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
|
||||||
|
if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="right-body">
|
<div className="right-body">
|
||||||
<div className="codebar">
|
<div className="codebar">
|
||||||
<span className="cb-badge editing">{t("Editing")}</span>
|
<span className="cb-badge editing">{t("Editing")}</span>
|
||||||
<span className="cb-name">{name}</span>
|
<span className="cb-name">{name}</span>
|
||||||
|
{lang ? <span className="cb-lines">{LANG_LABEL[lang]}</span> : null}
|
||||||
<span className="ce-actions">
|
<span className="ce-actions">
|
||||||
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
|
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
|
||||||
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea className="code-edit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)}
|
<div className="code editing">
|
||||||
onKeyDown={(e) => {
|
<div className="code-gutter" ref={gutRef}>{Array.from({ length: draftRows }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
|
||||||
if (e.key === "Escape") cancelEdit();
|
<div className="ce-wrap">
|
||||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
<pre className="ce-under" aria-hidden="true" ref={preRef}><code className="hljs" dangerouslySetInnerHTML={{ __html: draftHi + (draft.endsWith("\n") ? " " : "") }} /></pre>
|
||||||
// Tab inserts a tab instead of leaving the field
|
<textarea className="ce-ta" value={draft} spellCheck={false} autoFocus onScroll={syncScroll} onChange={(e) => setDraft(e.target.value)}
|
||||||
if (e.key === "Tab") {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault();
|
if (e.key === "Escape") cancelEdit();
|
||||||
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
||||||
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
// Tab inserts a tab instead of leaving the field
|
||||||
setDraft(nv);
|
if (e.key === "Tab") {
|
||||||
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
e.preventDefault();
|
||||||
}
|
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
||||||
}} />
|
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
||||||
|
setDraft(nv);
|
||||||
|
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/i18n.ts
86
src/i18n.ts
@ -489,6 +489,92 @@ const D: Record<string, Tr> = {
|
|||||||
".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." },
|
".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." },
|
||||||
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
|
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
|
||||||
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
|
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
|
||||||
|
// ---- offline work + reconcile ----
|
||||||
|
"Work Offline": { ru: "Работать офлайн", de: "Offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
|
||||||
|
"Reconcile Offline Work…": { ru: "Согласовать офлайн-правки…", de: "Offline-Arbeit abgleichen…", fr: "Réconcilier le travail hors ligne…", es: "Reconciliar trabajo sin conexión…" },
|
||||||
|
"Reconcile Offline Work": { ru: "Согласование офлайн-правок", de: "Offline-Arbeit abgleichen", fr: "Réconcilier le travail hors ligne", es: "Reconciliar trabajo sin conexión" },
|
||||||
|
"Working offline — edits stay local. Run Reconcile Offline Work to sync.": { ru: "Работаешь офлайн — правки остаются локально. Запусти «Согласовать офлайн-правки», чтобы синхронизировать.", de: "Offline-Modus — Änderungen bleiben lokal. Führe „Offline-Arbeit abgleichen“ aus, um zu synchronisieren.", fr: "Hors ligne — les modifications restent locales. Lance « Réconcilier le travail hors ligne » pour synchroniser.", es: "Sin conexión — los cambios quedan en local. Ejecuta «Reconciliar trabajo sin conexión» para sincronizar." },
|
||||||
|
"Back online.": { ru: "Снова онлайн.", de: "Wieder online.", fr: "De nouveau en ligne.", es: "De nuevo en línea." },
|
||||||
|
"Working offline — edits stay on your disk. Reconcile to bring them into a changelist.": { ru: "Работаешь офлайн — правки лежат на диске. Согласуй, чтобы собрать их в changelist.", de: "Offline — Änderungen liegen auf deiner Festplatte. Gleiche ab, um sie in eine Changelist zu übernehmen.", fr: "Hors ligne — les modifications sont sur votre disque. Réconciliez pour les regrouper dans une changelist.", es: "Sin conexión — los cambios están en tu disco. Reconcilia para llevarlos a una changelist." },
|
||||||
|
"Reconcile…": { ru: "Согласовать…", de: "Abgleichen…", fr: "Réconcilier…", es: "Reconciliar…" },
|
||||||
|
"Go online": { ru: "Выйти онлайн", de: "Online gehen", fr: "Passer en ligne", es: "Conectar" },
|
||||||
|
"Work offline instead": { ru: "Перейти в офлайн", de: "Stattdessen offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
|
||||||
|
"Make writable (offline edit)": { ru: "Сделать редактируемым (офлайн)", de: "Beschreibbar machen (offline)", fr: "Rendre modifiable (hors ligne)", es: "Hacer editable (sin conexión)" },
|
||||||
|
"No local path for the selection.": { ru: "Нет локального пути для выделения.", de: "Kein lokaler Pfad für die Auswahl.", fr: "Aucun chemin local pour la sélection.", es: "No hay ruta local para la selección." },
|
||||||
|
"{n} file(s) made writable for offline editing.": { ru: "{n} файл(ов) сделаны редактируемыми для офлайн-правки.", de: "{n} Datei(en) für Offline-Bearbeitung beschreibbar gemacht.", fr: "{n} fichier(s) rendus modifiables pour l'édition hors ligne.", es: "{n} archivo(s) hechos editables para edición sin conexión." },
|
||||||
|
"These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.": { ru: "Эти файлы изменились на диске без ведома сервера. Выбери, что внести в pending changelist: добавленные → p4 add, изменённые → p4 edit, пропавшие → p4 delete. Ничего не сабмитится — сначала проверишь и отправишь сам.", de: "Diese Dateien haben sich auf der Festplatte geändert, ohne dass der Server es weiß. Wähle, was in eine offene Changelist soll: neue → p4 add, geänderte → p4 edit, fehlende → p4 delete. Nichts wird übermittelt — du prüfst und übermittelst danach.", fr: "Ces fichiers ont changé sur le disque à l'insu du serveur. Choisis ceux à placer dans une changelist en attente : ajoutés → p4 add, modifiés → p4 edit, manquants → p4 delete. Rien n'est soumis — tu vérifies et soumets ensuite.", es: "Estos archivos cambiaron en disco sin que el servidor lo supiera. Elige cuáles llevar a una changelist pendiente: añadidos → p4 add, modificados → p4 edit, faltantes → p4 delete. Nada se envía — revisas y envías después." },
|
||||||
|
"added": { ru: "добавлено", de: "hinzugefügt", fr: "ajoutés", es: "añadidos" },
|
||||||
|
"modified": { ru: "изменено", de: "geändert", fr: "modifiés", es: "modificados" },
|
||||||
|
"deleted": { ru: "удалено", de: "gelöscht", fr: "supprimés", es: "eliminados" },
|
||||||
|
"Scanning workspace for offline changes…": { ru: "Сканирую воркспейс на офлайн-изменения…", de: "Arbeitsbereich wird auf Offline-Änderungen geprüft…", fr: "Analyse de l'espace de travail pour les modifications hors ligne…", es: "Analizando el espacio de trabajo en busca de cambios sin conexión…" },
|
||||||
|
"Nothing to reconcile — the workspace matches what the server knows.": { ru: "Согласовывать нечего — воркспейс совпадает с тем, что знает сервер.", de: "Nichts abzugleichen — der Arbeitsbereich stimmt mit dem Server überein.", fr: "Rien à réconcilier — l'espace de travail correspond à ce que le serveur connaît.", es: "Nada que reconciliar — el espacio de trabajo coincide con lo que el servidor conoce." },
|
||||||
|
"Reconcile {n} file(s)": { ru: "Согласовать {n} файл(ов)", de: "{n} Datei(en) abgleichen", fr: "Réconcilier {n} fichier(s)", es: "Reconciliar {n} archivo(s)" },
|
||||||
|
"Reconciled {n} file(s) into a changelist.": { ru: "Согласовано {n} файл(ов) в changelist.", de: "{n} Datei(en) in eine Changelist abgeglichen.", fr: "{n} fichier(s) réconciliés dans une changelist.", es: "{n} archivo(s) reconciliados en una changelist." },
|
||||||
|
// ---- menu tooltips ----
|
||||||
|
"Change which Perforce workspace (client) you're working in.": { ru: "Сменить воркспейс (client), в котором ты работаешь.", de: "Wechsle den Perforce-Arbeitsbereich (Client), in dem du arbeitest.", fr: "Change l'espace de travail Perforce (client) dans lequel tu travailles.", es: "Cambia el espacio de trabajo de Perforce (client) en el que trabajas." },
|
||||||
|
"Create a new workspace mapping depot folders to a local folder.": { ru: "Создать новый воркспейс, сопоставив папки депо с локальной папкой.", de: "Erstelle einen neuen Arbeitsbereich, der Depot-Ordner einem lokalen Ordner zuordnet.", fr: "Crée un espace de travail associant des dossiers du depot à un dossier local.", es: "Crea un espacio de trabajo que asigna carpetas del depot a una carpeta local." },
|
||||||
|
"Edit the current workspace spec — root folder and depot view mapping.": { ru: "Изменить спеку текущего воркспейса — корневую папку и маппинг депо.", de: "Bearbeite die aktuelle Arbeitsbereich-Spezifikation — Root-Ordner und Depot-View-Mapping.", fr: "Modifie la spec de l'espace de travail actuel — dossier racine et mappage du depot.", es: "Edita la spec del espacio de trabajo actual: carpeta raíz y mapeo del depot." },
|
||||||
|
"Pick which depot folder the app shows and syncs.": { ru: "Выбрать, какую папку депо приложение показывает и синхронизирует.", de: "Wähle, welchen Depot-Ordner die App anzeigt und synchronisiert.", fr: "Choisis quel dossier du depot l'app affiche et synchronise.", es: "Elige qué carpeta del depot muestra y sincroniza la app." },
|
||||||
|
"Sign out and return to the connection screen.": { ru: "Выйти и вернуться к экрану подключения.", de: "Abmelden und zum Verbindungsbildschirm zurückkehren.", fr: "Se déconnecter et revenir à l'écran de connexion.", es: "Cerrar sesión y volver a la pantalla de conexión." },
|
||||||
|
"Re-read state from the server.": { ru: "Перечитать состояние с сервера.", de: "Zustand vom Server neu einlesen.", fr: "Relire l'état depuis le serveur.", es: "Volver a leer el estado desde el servidor." },
|
||||||
|
"Scan the workspace for files changed outside the app (reconcile).": { ru: "Просканировать воркспейс на файлы, изменённые вне приложения (reconcile).", de: "Arbeitsbereich nach außerhalb der App geänderten Dateien durchsuchen (reconcile).", fr: "Analyser l'espace de travail pour les fichiers modifiés hors de l'app (reconcile).", es: "Analiza el espacio de trabajo en busca de archivos cambiados fuera de la app (reconcile)." },
|
||||||
|
"Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V.": { ru: "Включить/выключить офлайн-режим: не опрашивать сервер, править файлы локально и согласовать позже. Как в P4V.", de: "Offline-Modus umschalten: Server nicht abfragen, Dateien lokal bearbeiten und später abgleichen. Wie in P4V.", fr: "Basculer le mode hors ligne : ne plus interroger le serveur, éditer localement et réconcilier plus tard. Comme dans P4V.", es: "Alternar el modo sin conexión: dejar de consultar el servidor, editar en local y reconciliar después. Como en P4V." },
|
||||||
|
"Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them.": { ru: "Догнать сервер работой, сделанной офлайн: открывает локальные add / edit / delete в changelist для отправки.", de: "Den Server mit offline erledigter Arbeit nachziehen: öffnet lokale Adds / Edits / Deletes in einer Changelist zum Übermitteln.", fr: "Mettre le serveur à jour avec le travail hors ligne : ouvre les ajouts / modifications / suppressions locaux dans une changelist à soumettre.", es: "Poner al día el servidor con el trabajo sin conexión: abre adds / edits / deletes locales en una changelist para enviarlos." },
|
||||||
|
"Make the workspace exactly match the depot — discards un-opened local changes.": { ru: "Привести воркспейс точно к состоянию депо — отбрасывает неоткрытые локальные правки.", de: "Arbeitsbereich exakt an das Depot angleichen — verwirft nicht geöffnete lokale Änderungen.", fr: "Aligner exactement l'espace de travail sur le depot — abandonne les modifications locales non ouvertes.", es: "Hacer que el espacio de trabajo coincida exactamente con el depot — descarta cambios locales no abiertos." },
|
||||||
|
"Sync the workspace to the newest revision (pull).": { ru: "Синхронизировать воркспейс до последней ревизии (pull).", de: "Arbeitsbereich auf die neueste Revision synchronisieren (Pull).", fr: "Synchroniser l'espace de travail sur la dernière révision (pull).", es: "Sincronizar el espacio de trabajo a la revisión más reciente (pull)." },
|
||||||
|
"Sync the workspace to a specific changelist, label or date.": { ru: "Синхронизировать воркспейс до конкретного changelist, метки или даты.", de: "Arbeitsbereich auf eine bestimmte Changelist, ein Label oder ein Datum synchronisieren.", fr: "Synchroniser l'espace de travail sur une changelist, un label ou une date précis.", es: "Sincronizar el espacio de trabajo a una changelist, etiqueta o fecha concretas." },
|
||||||
|
"Save selected files into a local pending changelist (not sent yet).": { ru: "Сохранить выбранные файлы в локальный pending changelist (пока не отправлено).", de: "Ausgewählte Dateien in eine lokale offene Changelist speichern (noch nicht gesendet).", fr: "Enregistrer les fichiers sélectionnés dans une changelist locale en attente (pas encore envoyée).", es: "Guardar los archivos seleccionados en una changelist local pendiente (aún no enviada)." },
|
||||||
|
"Send pending changelists to the server (push).": { ru: "Отправить pending changelists на сервер (push).", de: "Offene Changelists an den Server senden (Push).", fr: "Envoyer les changelists en attente au serveur (push).", es: "Enviar las changelists pendientes al servidor (push)." },
|
||||||
|
"Resolve merge conflicts left after a sync.": { ru: "Разрешить конфликты слияния, оставшиеся после синхронизации.", de: "Nach einer Synchronisierung verbliebene Merge-Konflikte auflösen.", fr: "Résoudre les conflits de fusion restants après une synchronisation.", es: "Resolver los conflictos de fusión que quedan tras una sincronización." },
|
||||||
|
"Discard every open change and restore depot versions.": { ru: "Отменить все открытые правки и восстановить версии из депо.", de: "Alle offenen Änderungen verwerfen und Depot-Versionen wiederherstellen.", fr: "Abandonner toutes les modifications ouvertes et restaurer les versions du depot.", es: "Descartar todos los cambios abiertos y restaurar las versiones del depot." },
|
||||||
|
"Show the panel of recently run Perforce commands.": { ru: "Показать панель недавно выполненных команд Perforce.", de: "Zeige das Panel der zuletzt ausgeführten Perforce-Befehle.", fr: "Afficher le panneau des commandes Perforce récemment exécutées.", es: "Mostrar el panel de comandos de Perforce ejecutados recientemente." },
|
||||||
|
"Open an embedded terminal for raw p4 commands.": { ru: "Открыть встроенный терминал для сырых команд p4.", de: "Ein eingebettetes Terminal für rohe p4-Befehle öffnen.", fr: "Ouvrir un terminal intégré pour les commandes p4 brutes.", es: "Abrir una terminal integrada para comandos p4 en crudo." },
|
||||||
|
"Show output from the headless Unreal preview process.": { ru: "Показать вывод фонового процесса Unreal-превью.", de: "Ausgabe des Headless-Unreal-Vorschauprozesses anzeigen.", fr: "Afficher la sortie du processus de prévisualisation Unreal sans interface.", es: "Mostrar la salida del proceso de vista previa de Unreal sin interfaz." },
|
||||||
|
"See every file currently exclusively locked and by whom.": { ru: "Посмотреть все файлы, сейчас взятые на эксклюзивный лок, и кем.", de: "Alle aktuell exklusiv gesperrten Dateien und von wem sehen.", fr: "Voir tous les fichiers actuellement verrouillés en exclusivité et par qui.", es: "Ver todos los archivos bloqueados en exclusiva ahora mismo y por quién." },
|
||||||
|
"Find files anywhere in the depot by name or path.": { ru: "Найти файлы в любом месте депо по имени или пути.", de: "Dateien überall im Depot nach Name oder Pfad finden.", fr: "Trouver des fichiers n'importe où dans le depot par nom ou chemin.", es: "Buscar archivos en cualquier lugar del depot por nombre o ruta." },
|
||||||
|
"Set which binary asset types are exclusive-checkout (+l) so only one person edits them.": { ru: "Задать, какие бинарные типы ассетов берутся эксклюзивно (+l), чтобы их правил только один человек.", de: "Festlegen, welche binären Asset-Typen exklusiv ausgecheckt werden (+l), damit nur eine Person sie bearbeitet.", fr: "Définir quels types d'assets binaires sont en extraction exclusive (+l) pour qu'une seule personne les édite.", es: "Definir qué tipos de assets binarios son de extracción exclusiva (+l) para que solo una persona los edite." },
|
||||||
|
"Named snapshots of file revisions: tag files, or sync to a label.": { ru: "Именованные снимки ревизий файлов: пометить файлы или синхронизироваться на метку.", de: "Benannte Schnappschüsse von Dateirevisionen: Dateien taggen oder auf ein Label synchronisieren.", fr: "Instantanés nommés de révisions de fichiers : taguer des fichiers ou synchroniser sur un label.", es: "Instantáneas con nombre de revisiones de archivos: etiquetar archivos o sincronizar a una etiqueta." },
|
||||||
|
"Move changes between branches of the depot.": { ru: "Переносить изменения между ветками депо.", de: "Änderungen zwischen Depot-Branches verschieben.", fr: "Déplacer des modifications entre les branches du depot.", es: "Mover cambios entre ramas del depot." },
|
||||||
|
"Switch stream, merge down from the parent, or copy up to it.": { ru: "Переключить стрим, влить сверху от родителя (merge down) или протолкнуть наверх (copy up).", de: "Stream wechseln, vom Parent mergen (merge down) oder nach oben kopieren (copy up).", fr: "Changer de stream, fusionner depuis le parent (merge down) ou remonter (copy up).", es: "Cambiar de stream, fusionar desde el padre (merge down) o copiar hacia arriba (copy up)." },
|
||||||
|
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
|
||||||
|
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
|
||||||
|
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
|
||||||
|
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
|
||||||
|
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
|
||||||
|
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
|
||||||
|
// ---- files & sizes explorer ----
|
||||||
|
"Files & Sizes…": { ru: "Файлы и размеры…", de: "Dateien & Größen…", fr: "Fichiers et tailles…", es: "Archivos y tamaños…" },
|
||||||
|
"Files & Sizes": { ru: "Файлы и размеры", de: "Dateien & Größen", fr: "Fichiers et tailles", es: "Archivos y tamaños" },
|
||||||
|
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs.": { ru: "Просматривай Depot и свой Workspace деревом и смотри, сколько весит каждый файл и папка.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh, wie viel jede Datei und jeder Ordner wiegt.", fr: "Parcours le Depot et ton espace de travail sous forme d'arbre et vois le poids de chaque fichier et dossier.", es: "Explora el Depot y tu espacio de trabajo como un árbol y ve cuánto pesa cada archivo y carpeta." },
|
||||||
|
"Depot": { ru: "Depot", de: "Depot", fr: "Depot", es: "Depot" },
|
||||||
|
"Whole depot": { ru: "Весь depot", de: "Gesamtes Depot", fr: "Depot entier", es: "Todo el depot" },
|
||||||
|
"Workspace root": { ru: "Корень workspace", de: "Arbeitsbereich-Root", fr: "Racine de l'espace de travail", es: "Raíz del espacio de trabajo" },
|
||||||
|
"{n} items": { ru: "{n} эл.", de: "{n} Einträge", fr: "{n} éléments", es: "{n} elementos" },
|
||||||
|
"Weighing…": { ru: "Считаю вес…", de: "Wird gewogen…", fr: "Calcul du poids…", es: "Calculando tamaño…" },
|
||||||
|
"Empty.": { ru: "Пусто.", de: "Leer.", fr: "Vide.", es: "Vacío." },
|
||||||
|
"Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.": { ru: "Размеры берутся с сервера (p4 sizes): суммарный объём, который каждая папка депо хранит по всем файлам. Сортировка по убыванию веса.", de: "Größen kommen vom Server (p4 sizes): die Gesamtbytes, die jeder Depot-Ordner über alle Dateien speichert. Nach Größe absteigend sortiert.", fr: "Les tailles viennent du serveur (p4 sizes) : le total d'octets que chaque dossier du depot stocke sur tous ses fichiers. Trié du plus lourd au plus léger.", es: "Los tamaños vienen del servidor (p4 sizes): el total de bytes que cada carpeta del depot almacena en todos sus archivos. Ordenado de mayor a menor." },
|
||||||
|
"Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.": { ru: "Размеры измеряются на твоём диске: сколько реально занимает каждая папка и файл локального workspace. Сортировка по убыванию веса.", de: "Größen werden auf deiner Festplatte gemessen: wie viel jeder Ordner und jede Datei deines lokalen Arbeitsbereichs tatsächlich belegt. Nach Größe absteigend sortiert.", fr: "Les tailles sont mesurées sur ton disque : l'espace réellement occupé par chaque dossier et fichier de ton espace de travail local. Trié du plus lourd au plus léger.", es: "Los tamaños se miden en tu disco: cuánto ocupa realmente cada carpeta y archivo de tu espacio de trabajo local. Ordenado de mayor a menor." },
|
||||||
|
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.": { ru: "Просматривай Depot и Workspace деревом и смотри вес каждого файла и папки. Сортировка по убыванию.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh das Gewicht jeder Datei und jedes Ordners. Nach Größe absteigend.", fr: "Parcours le Depot et ton espace de travail en arbre et vois le poids de chaque fichier et dossier. Trié du plus lourd au plus léger.", es: "Explora el Depot y tu espacio de trabajo como árbol y ve el peso de cada archivo y carpeta. De mayor a menor." },
|
||||||
|
// ---- view panel (File Viewer / File Tree tabs) ----
|
||||||
|
"File Viewer": { ru: "Просмотр файла", de: "Dateivorschau", fr: "Visionneuse", es: "Visor de archivos" },
|
||||||
|
"File Tree": { ru: "Дерево файлов", de: "Dateibaum", fr: "Arborescence", es: "Árbol de archivos" },
|
||||||
|
"No panel open.\nAdd File Viewer or File Tree from the Window menu.": { ru: "Нет открытых панелей.\nДобавь «Просмотр файла» или «Дерево файлов» из меню Window.", de: "Kein Panel geöffnet.\nFüge „Dateivorschau“ oder „Dateibaum“ über das Menü Window hinzu.", fr: "Aucun panneau ouvert.\nAjoute « Visionneuse » ou « Arborescence » depuis le menu Window.", es: "Ningún panel abierto.\nAñade «Visor de archivos» o «Árbol de archivos» desde el menú Window." },
|
||||||
|
"The preview panel for the selected file (image, 3D model, code, diff).": { ru: "Панель предпросмотра выбранного файла (картинка, 3D-модель, код, diff).", de: "Das Vorschau-Panel für die ausgewählte Datei (Bild, 3D-Modell, Code, Diff).", fr: "Le panneau de prévisualisation du fichier sélectionné (image, modèle 3D, code, diff).", es: "El panel de vista previa del archivo seleccionado (imagen, modelo 3D, código, diff)." },
|
||||||
|
"A folder hierarchy of the Depot and your Workspace, docked into the view panel.": { ru: "Иерархия папок Depot и Workspace, встроенная в панель просмотра.", de: "Eine Ordnerhierarchie des Depots und deines Arbeitsbereichs, angedockt im Ansichts-Panel.", fr: "Une hiérarchie de dossiers du Depot et de ton espace de travail, ancrée dans le panneau.", es: "Una jerarquía de carpetas del Depot y tu espacio de trabajo, anclada en el panel." },
|
||||||
|
"Show in File Tree": { ru: "Показать в дереве файлов", de: "Im Dateibaum zeigen", fr: "Afficher dans l'arborescence", es: "Mostrar en el árbol de archivos" },
|
||||||
|
"Total size of files in this changelist": { ru: "Суммарный вес файлов в этом ченджлисте", de: "Gesamtgröße der Dateien in dieser Changelist", fr: "Taille totale des fichiers de cette changelist", es: "Tamaño total de los archivos de esta changelist" },
|
||||||
|
// ---- server activity / behind + tray notifications ----
|
||||||
|
"Server has newer content — {n} file(s) behind.": { ru: "На сервере есть свежее — ты отстал на {n} файл(ов).", de: "Der Server hat neueren Inhalt — {n} Datei(en) im Rückstand.", fr: "Le serveur a du contenu plus récent — {n} fichier(s) en retard.", es: "El servidor tiene contenido más nuevo — {n} archivo(s) por detrás." },
|
||||||
|
"Your workspace is behind": { ru: "Твой воркспейс отстал", de: "Dein Arbeitsbereich ist veraltet", fr: "Ton espace de travail est en retard", es: "Tu espacio de trabajo está desactualizado" },
|
||||||
|
"{n} file(s) on the server are newer than your copy. Get Latest to update.": { ru: "{n} файл(ов) на сервере новее твоих. Нажми Get Latest, чтобы обновить.", de: "{n} Datei(en) auf dem Server sind neuer als deine. Get Latest zum Aktualisieren.", fr: "{n} fichier(s) sur le serveur sont plus récents que les tiens. Fais Get Latest pour mettre à jour.", es: "{n} archivo(s) del servidor son más nuevos que los tuyos. Pulsa Get Latest para actualizar." },
|
||||||
|
"Exbyte Depot is still running": { ru: "Exbyte Depot всё ещё работает", de: "Exbyte Depot läuft weiter", fr: "Exbyte Depot fonctionne toujours", es: "Exbyte Depot sigue en ejecución" },
|
||||||
|
"It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.": { ru: "Он в системном трее и будет присылать уведомления о сабмитах коллег. ПКМ по иконке в трее — выход.", de: "Es liegt im System-Tray und benachrichtigt dich über Submits von Kollegen. Rechtsklick auf das Tray-Symbol zum Beenden.", fr: "Il est dans la zone de notification et t'avertira des soumissions de tes collègues. Clic droit sur l'icône pour quitter.", es: "Está en la bandeja del sistema y te avisará de los envíos de tus compañeros. Clic derecho en el icono para salir." },
|
||||||
|
// ---- audio/video preview + docked locks ----
|
||||||
|
"Audio": { ru: "Аудио", de: "Audio", fr: "Audio", es: "Audio" },
|
||||||
|
"Video": { ru: "Видео", de: "Video", fr: "Vidéo", es: "Vídeo" },
|
||||||
|
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
|
||||||
|
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
|
||||||
|
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||||
|
|||||||
22
src/p4.ts
22
src/p4.ts
@ -74,6 +74,12 @@ export const p4 = {
|
|||||||
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
|
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
|
||||||
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
|
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
|
||||||
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
|
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
|
||||||
|
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
|
||||||
|
changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }),
|
||||||
|
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
|
||||||
|
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
|
||||||
|
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
|
||||||
|
setWritable: (paths: string[], writable: boolean) => invoke<string>("p4_set_writable", { paths, writable }),
|
||||||
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
|
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
|
||||||
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
|
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
|
||||||
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
|
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
|
||||||
@ -152,6 +158,7 @@ export const p4 = {
|
|||||||
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
|
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
|
||||||
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
||||||
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
||||||
|
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
|
||||||
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
||||||
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
||||||
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
||||||
@ -188,6 +195,17 @@ export function leaf(path?: string): string {
|
|||||||
|
|
||||||
// per-file revision history parsed from p4 filelog's indexed fields
|
// per-file revision history parsed from p4 filelog's indexed fields
|
||||||
export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string }
|
export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string }
|
||||||
|
/** One entry (file or folder) in the Depot/Workspace size explorer. `size` is bytes. */
|
||||||
|
export interface FsEntry { name: string; path: string; isDir: boolean; size: number; fileCount: number }
|
||||||
|
/** Human-readable byte size, e.g. 1536 → "1.5 KB". */
|
||||||
|
export function humanSize(n: number): string {
|
||||||
|
if (!n) return "0 B";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const u = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let v = n, i = -1;
|
||||||
|
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
|
||||||
|
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
||||||
|
}
|
||||||
export function filelogRevs(v: Record<string, unknown>): FileRev[] {
|
export function filelogRevs(v: Record<string, unknown>): FileRev[] {
|
||||||
const out: FileRev[] = [];
|
const out: FileRev[] = [];
|
||||||
for (let i = 0; ; i++) {
|
for (let i = 0; ; i++) {
|
||||||
@ -265,12 +283,14 @@ export function splitPath(p?: string): { name: string; dir: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// classify by extension for the preview panel
|
// classify by extension for the preview panel
|
||||||
export type Kind = "model" | "uasset" | "image" | "code";
|
export type Kind = "model" | "uasset" | "image" | "audio" | "video" | "code";
|
||||||
export function kindOf(name: string): Kind {
|
export function kindOf(name: string): Kind {
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
|
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
|
||||||
if (/\.(uasset|umap)$/.test(n)) return "uasset";
|
if (/\.(uasset|umap)$/.test(n)) return "uasset";
|
||||||
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
|
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
|
||||||
|
if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/.test(n)) return "audio";
|
||||||
|
if (/\.(mp4|webm|mov|m4v|ogv)$/.test(n)) return "video";
|
||||||
return "code";
|
return "code";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user