Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c24d45bf9 | |||
| ec8f31c654 | |||
| 5d647f8e92 | |||
| 28a3989095 | |||
| ecc5b8e9bf | |||
| 2069f0fed4 | |||
| 7092c9d1dc | |||
| fdaf126c00 | |||
| 2811aec6e8 | |||
| 6cfeb27bdd | |||
| 0fc0af38da | |||
| e2ad438ed4 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.2.4",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -960,7 +960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.4"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.4"
|
||||
version = "0.3.0"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
|
||||
@ -324,6 +324,116 @@ async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>,
|
||||
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.
|
||||
/// Empty pending changelists (leftovers from a reverted/aborted commit) are
|
||||
/// cleaned up so they never show a phantom "ready to Submit" or fail submit.
|
||||
@ -485,6 +595,46 @@ async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result<String, St
|
||||
Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg })
|
||||
}
|
||||
|
||||
/// List labels on the server (most-recently-updated first).
|
||||
#[tauri::command]
|
||||
async fn p4_labels(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["labels", "-m", "300"])
|
||||
}
|
||||
|
||||
/// Tag revisions with a label (auto-creates the label if new). `rev` optional:
|
||||
/// a changelist, another label, or a date; empty = head. Scope confines it.
|
||||
#[tauri::command]
|
||||
async fn p4_label_tag(state: State<'_, AppState>, label: String, scope: String, rev: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
return Err("No label name".into());
|
||||
}
|
||||
let mut spec = scope_spec(&scope);
|
||||
let rev = rev.trim().trim_start_matches(['@', '#']);
|
||||
if !rev.is_empty() {
|
||||
spec = format!("{spec}@{rev}");
|
||||
}
|
||||
run_text(&conn, &["tag", "-l", label, &spec])
|
||||
}
|
||||
|
||||
/// Sync the workspace to an exact revision: a changelist number, a label name,
|
||||
/// or a date (`YYYY/MM/DD`). `rev` is appended to the scope spec as `@rev`.
|
||||
#[tauri::command]
|
||||
async fn p4_sync_to(state: State<'_, AppState>, scope: String, rev: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let rev = rev.trim();
|
||||
if rev.is_empty() {
|
||||
return Err("No revision specified".into());
|
||||
}
|
||||
// strip a leading @ or # if the user typed one
|
||||
let rev = rev.trim_start_matches(['@', '#']);
|
||||
let spec = format!("{}@{}", scope_spec(&scope), rev);
|
||||
let msg = run_stream(&conn, &["sync", &spec], "sync")?;
|
||||
Ok(if msg.is_empty() { "Already at that revision — nothing to sync.".into() } else { msg })
|
||||
}
|
||||
|
||||
/// Unified diff of an opened file vs. the have revision.
|
||||
#[tauri::command]
|
||||
async fn p4_diff(state: State<'_, AppState>, file: String) -> Result<String, String> {
|
||||
@ -560,6 +710,57 @@ async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<
|
||||
run_stream(&conn, &["submit", "-c", &change], "submit")
|
||||
}
|
||||
|
||||
/// Submit a shelved changelist directly from the server (`submit -e`), without
|
||||
/// needing the files in the workspace.
|
||||
#[tauri::command]
|
||||
async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
run_stream(&conn, &["submit", "-e", change.trim()], "submit")
|
||||
}
|
||||
|
||||
/// Submit a numbered changelist with options: `reopen` keeps the files checked
|
||||
/// out after submit (`-r`); `revert_unchanged` drops files with no real change
|
||||
/// (`-f revertunchanged`).
|
||||
#[tauri::command]
|
||||
async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool, revert_unchanged: bool) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let ch = change.trim().to_string();
|
||||
let mut args: Vec<String> = vec!["submit".into(), "-c".into(), ch];
|
||||
if reopen {
|
||||
args.push("-r".into());
|
||||
}
|
||||
if revert_unchanged {
|
||||
args.push("-f".into());
|
||||
args.push("revertunchanged".into());
|
||||
}
|
||||
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_stream(&conn, &refs, "submit")
|
||||
}
|
||||
|
||||
/// Path of the workspace `.p4ignore` file (at the client root).
|
||||
fn ignore_path(conn: &Conn) -> Option<std::path::PathBuf> {
|
||||
if conn.root.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::Path::new(&conn.root).join(".p4ignore"))
|
||||
}
|
||||
|
||||
/// Read the workspace `.p4ignore` (empty string if none yet).
|
||||
#[tauri::command]
|
||||
async fn p4_ignore_read(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let p = ignore_path(&conn).ok_or("No workspace root known")?;
|
||||
Ok(std::fs::read_to_string(&p).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Write the workspace `.p4ignore`.
|
||||
#[tauri::command]
|
||||
async fn p4_ignore_write(state: State<'_, AppState>, content: String) -> Result<(), String> {
|
||||
let conn = current(&state)?;
|
||||
let p = ignore_path(&conn).ok_or("No workspace root known")?;
|
||||
std::fs::write(&p, content).map_err(|e| format!("Could not write .p4ignore: {e}"))
|
||||
}
|
||||
|
||||
/// "Uncommit": move files back into the default changelist (like `git reset`).
|
||||
/// The now-empty numbered changelist is cleaned up on the next `p4_changes`.
|
||||
#[tauri::command]
|
||||
@ -1314,6 +1515,87 @@ async fn p4_unlock(state: State<'_, AppState>, files: Vec<String>) -> Result<Str
|
||||
run_text(&conn, &args)
|
||||
}
|
||||
|
||||
/// Read the server typemap as a list of "TYPE PATTERN" entries.
|
||||
/// (Empty list if the typemap is unset. Requires admin to *change*, not to read.)
|
||||
#[tauri::command]
|
||||
async fn p4_typemap_get(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||
let conn = current(&state)?;
|
||||
let spec = run_text(&conn, &["typemap", "-o"])?;
|
||||
let mut entries = Vec::new();
|
||||
let mut in_map = false;
|
||||
for line in spec.lines() {
|
||||
if line.starts_with("TypeMap:") {
|
||||
in_map = true;
|
||||
continue;
|
||||
}
|
||||
if in_map {
|
||||
if line.starts_with('\t') || line.starts_with(' ') {
|
||||
let e = line.trim();
|
||||
if !e.is_empty() && !e.starts_with('#') {
|
||||
entries.push(e.to_string());
|
||||
}
|
||||
} else if !line.trim().is_empty() {
|
||||
break; // reached the next spec field
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Replace the server typemap with the given "TYPE PATTERN" entries.
|
||||
/// Requires admin rights on the server.
|
||||
#[tauri::command]
|
||||
async fn p4_typemap_set(state: State<'_, AppState>, entries: Vec<String>) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let mut spec = String::from("TypeMap:\n");
|
||||
for e in &entries {
|
||||
let e = e.trim();
|
||||
if !e.is_empty() {
|
||||
spec.push('\t');
|
||||
spec.push_str(e);
|
||||
spec.push('\n');
|
||||
}
|
||||
}
|
||||
let mut child = p4_cmd(&conn, false)
|
||||
.args(["typemap", "-i"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start p4: {e}"))?;
|
||||
if let Some(si) = child.stdin.as_mut() {
|
||||
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = decode(&out.stderr);
|
||||
let msg = if err.trim().is_empty() { decode(&out.stdout) } else { err };
|
||||
let low = msg.to_lowercase();
|
||||
return Err(if low.contains("permission") || low.contains("protect") || low.contains("admin") || low.contains("not allowed") {
|
||||
"You need admin rights on the server to change the typemap.".to_string()
|
||||
} else {
|
||||
msg.trim().to_string()
|
||||
});
|
||||
}
|
||||
Ok(decode(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Change the Perforce file type of opened files (e.g. add "+l" exclusive lock).
|
||||
/// `filetype` is passed to `p4 reopen -t`, so "+l" adds the modifier and
|
||||
/// "binary+l" sets the full type.
|
||||
#[tauri::command]
|
||||
async fn p4_reopen_type(state: State<'_, AppState>, files: Vec<String>, filetype: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
if files.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let mut args: Vec<&str> = vec!["reopen", "-t", &filetype];
|
||||
for f in &files {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
run_text(&conn, &args)
|
||||
}
|
||||
|
||||
/// Extract the largest embedded PNG from a byte blob — this is how an uncooked
|
||||
/// Unreal `.uasset` stores its Content-Browser thumbnail (PNG-compressed in the
|
||||
/// package thumbnail table). We scan for the PNG signature → IEND and keep the
|
||||
@ -1662,6 +1944,28 @@ async fn p4_resolve_file(state: State<'_, AppState>, file: String, mode: String)
|
||||
}
|
||||
}
|
||||
|
||||
/// Propagate changes between branches/streams: op is "merge" (with resolve),
|
||||
/// "copy" (overwrite target), or "integrate" (classic). Opens the affected files
|
||||
/// into a fresh numbered changelist; the user then resolves (if needed) and
|
||||
/// submits via the normal pending-changelist flow. Returns the changelist number.
|
||||
#[tauri::command]
|
||||
async fn p4_branch_op(state: State<'_, AppState>, op: String, source: String, target: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let cmd = match op.as_str() {
|
||||
"merge" => "merge",
|
||||
"copy" => "copy",
|
||||
_ => "integrate",
|
||||
};
|
||||
let src = source.trim();
|
||||
let tgt = target.trim();
|
||||
if src.is_empty() || tgt.is_empty() {
|
||||
return Err("Source and target paths are required".into());
|
||||
}
|
||||
let cl = create_changelist(&conn, &format!("{cmd} {src} -> {tgt}"))?;
|
||||
let out = run_text(&conn, &[cmd, "-c", &cl, src, tgt]).unwrap_or_default();
|
||||
Ok(format!("CL {cl}\n{}", out.trim()))
|
||||
}
|
||||
|
||||
/// Unified diff between two exact revisions (`p4 diff2 -du`).
|
||||
#[tauri::command]
|
||||
async fn p4_diff2(state: State<'_, AppState>, spec1: String, spec2: String) -> Result<String, String> {
|
||||
@ -1724,6 +2028,243 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
|
||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// Preview `p4 clean` — files that would be deleted (local extras), restored
|
||||
/// (locally deleted) or refreshed (locally modified but not opened) to make the
|
||||
/// workspace exactly match the depot. Non-destructive (`-n`).
|
||||
#[tauri::command]
|
||||
async fn p4_clean_preview(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["clean", "-n", &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") {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `p4 clean`: make the workspace match the depot, discarding un-opened
|
||||
/// local edits/adds/deletes. Streams progress.
|
||||
#[tauri::command]
|
||||
async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync")?;
|
||||
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// per-revision changelist, action, user, time and description. Returns the
|
||||
/// tagged object with rev0/change0/action0/… arrays.
|
||||
#[tauri::command]
|
||||
async fn p4_filelog(state: State<'_, AppState>, depot: String) -> Result<Value, String> {
|
||||
let conn = current(&state)?;
|
||||
if depot.trim().is_empty() {
|
||||
return Err("No file".into());
|
||||
}
|
||||
let v = run_json(&conn, &["filelog", "-l", "-m", "150", depot.trim()])?;
|
||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// List jobs on the server (defect/task tracking).
|
||||
#[tauri::command]
|
||||
async fn p4_jobs(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["jobs", "-m", "300"])
|
||||
}
|
||||
|
||||
/// Link a job to a pending changelist (`p4 fix -c <change> <job>`).
|
||||
#[tauri::command]
|
||||
async fn p4_fix(state: State<'_, AppState>, job: String, change: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let job = job.trim();
|
||||
let change = change.trim();
|
||||
if job.is_empty() || change.is_empty() {
|
||||
return Err("Job and changelist are required".into());
|
||||
}
|
||||
run_text(&conn, &["fix", "-c", change, job])
|
||||
}
|
||||
|
||||
/// Get a job spec as editable text (unknown name → new job template).
|
||||
#[tauri::command]
|
||||
async fn p4_job_spec(state: State<'_, AppState>, job: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let j = job.trim();
|
||||
if j.is_empty() {
|
||||
run_text(&conn, &["job", "-o"]) // template for a new job
|
||||
} else {
|
||||
run_text(&conn, &["job", "-o", j])
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a job spec (`p4 job -i`), creating it if new.
|
||||
#[tauri::command]
|
||||
async fn p4_job_save(state: State<'_, AppState>, spec: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let mut child = p4_cmd(&conn, false)
|
||||
.args(["job", "-i"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start p4: {e}"))?;
|
||||
if let Some(si) = child.stdin.as_mut() {
|
||||
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = decode(&out.stderr);
|
||||
return Err(if err.trim().is_empty() { decode(&out.stdout).trim().to_string() } else { err.trim().to_string() });
|
||||
}
|
||||
Ok(decode(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// List streams on the server.
|
||||
#[tauri::command]
|
||||
async fn p4_streams(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["streams", "-m", "300"])
|
||||
}
|
||||
|
||||
/// Switch the current workspace to a stream (`p4 client -s -S <stream>`), then
|
||||
/// return the refreshed `p4 info`. The workspace View is rebuilt from the stream.
|
||||
#[tauri::command]
|
||||
async fn p4_switch_stream(state: State<'_, AppState>, stream: String) -> Result<Value, String> {
|
||||
let conn = current(&state)?;
|
||||
let stream = stream.trim();
|
||||
if stream.is_empty() {
|
||||
return Err("No stream".into());
|
||||
}
|
||||
run_text(&conn, &["client", "-s", "-S", stream])?;
|
||||
let info = run_json(&conn, &["info"])?;
|
||||
let info = info.into_iter().next().unwrap_or(Value::Null);
|
||||
let root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string();
|
||||
state.0.lock().unwrap_or_else(|e| e.into_inner()).root = root;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Stream integration: direction "down" merges from the parent into the stream
|
||||
/// (merge down), "up" copies the stream to its parent (copy up). Opens the work
|
||||
/// into a fresh changelist; resolve (if needed) and submit as usual.
|
||||
#[tauri::command]
|
||||
async fn p4_stream_integ(state: State<'_, AppState>, stream: String, direction: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let stream = stream.trim();
|
||||
if stream.is_empty() {
|
||||
return Err("No stream".into());
|
||||
}
|
||||
let (verb, extra, label) = if direction == "up" {
|
||||
("copy", vec!["-r"], "copy up")
|
||||
} else {
|
||||
("merge", vec![], "merge down")
|
||||
};
|
||||
let cl = create_changelist(&conn, &format!("{label} {stream}"))?;
|
||||
let mut args: Vec<&str> = vec![verb, "-S", stream, "-c", &cl];
|
||||
args.extend(extra);
|
||||
let out = run_text(&conn, &args).unwrap_or_default();
|
||||
Ok(format!("CL {cl}\n{}", out.trim()))
|
||||
}
|
||||
|
||||
/// Get a workspace (client) spec as editable text. An unknown name returns a
|
||||
/// fresh template — used to create a new workspace.
|
||||
#[tauri::command]
|
||||
async fn p4_client_spec(state: State<'_, AppState>, client: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
if client.trim().is_empty() {
|
||||
return Err("No workspace name".into());
|
||||
}
|
||||
run_text(&conn, &["client", "-o", client.trim()])
|
||||
}
|
||||
|
||||
/// Save a workspace (client) spec (`p4 client -i`). Creates it if new.
|
||||
#[tauri::command]
|
||||
async fn p4_client_save(state: State<'_, AppState>, spec: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let mut child = p4_cmd(&conn, false)
|
||||
.args(["client", "-i"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start p4: {e}"))?;
|
||||
if let Some(si) = child.stdin.as_mut() {
|
||||
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = decode(&out.stderr);
|
||||
return Err(if err.trim().is_empty() { decode(&out.stdout).trim().to_string() } else { err.trim().to_string() });
|
||||
}
|
||||
Ok(decode(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Switch the active workspace (client) without re-authenticating.
|
||||
#[tauri::command]
|
||||
async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result<Value, String> {
|
||||
@ -1924,6 +2465,32 @@ pub fn run() {
|
||||
p4_opened_all,
|
||||
p4_lock,
|
||||
p4_unlock,
|
||||
p4_typemap_get,
|
||||
p4_typemap_set,
|
||||
p4_reopen_type,
|
||||
p4_sync_to,
|
||||
p4_labels,
|
||||
p4_label_tag,
|
||||
p4_branch_op,
|
||||
p4_streams,
|
||||
p4_switch_stream,
|
||||
p4_stream_integ,
|
||||
p4_filelog,
|
||||
p4_clean_preview,
|
||||
p4_clean_apply,
|
||||
p4_reconcile_preview,
|
||||
p4_reconcile_apply,
|
||||
p4_set_writable,
|
||||
p4_submit_shelved,
|
||||
p4_submit_opts,
|
||||
p4_ignore_read,
|
||||
p4_ignore_write,
|
||||
p4_jobs,
|
||||
p4_fix,
|
||||
p4_job_spec,
|
||||
p4_job_save,
|
||||
p4_client_spec,
|
||||
p4_client_save,
|
||||
uasset_thumbnail,
|
||||
uasset_export_3d,
|
||||
p4_shelve,
|
||||
@ -1945,6 +2512,8 @@ pub fn run() {
|
||||
read_ai_key,
|
||||
save_ai_key,
|
||||
p4_dirs,
|
||||
depot_ls,
|
||||
fs_ls,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.2.4",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
58
src/App.css
58
src/App.css
@ -560,7 +560,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
display:flex;align-items:center;justify-content:center;gap:9px}
|
||||
.connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px}
|
||||
.spin{animation:spin 1s linear infinite}
|
||||
.ldr{width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
|
||||
.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{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0}
|
||||
@ -768,8 +768,19 @@ 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.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)}
|
||||
/* 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)}
|
||||
.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)}
|
||||
.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{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}
|
||||
@ -780,6 +791,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.srch-list{flex:1;overflow-y:auto;padding:6px 10px 12px}
|
||||
.srch-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:9px;transition:background .12s}
|
||||
.srch-row:hover{background:var(--hover)}
|
||||
.srch-row.on{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
|
||||
.srch-body{flex:1;display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.srch-body .n{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.srch-body .p{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
@ -794,6 +806,21 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.rslv-act svg{width:14px;height:14px;display:block}
|
||||
.lk-who{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-size:11.5px;color:var(--muted);max-width:170px}
|
||||
.lk-client{font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px}
|
||||
.tm-hint{padding:10px 16px;font-size:11.5px;line-height:1.5;color:var(--faint);border-bottom:1px solid var(--border-soft)}
|
||||
/* file-history timeline */
|
||||
.flog-body{flex:1;overflow:auto;padding:6px 14px 14px}
|
||||
.flog-row{display:flex;gap:12px;align-items:flex-start;padding:8px 6px}
|
||||
.flog-row:hover{background:var(--hover);border-radius:8px}
|
||||
.flog-rail{flex:0 0 14px;display:flex;flex-direction:column;align-items:center;align-self:stretch;position:relative}
|
||||
.flog-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;margin-top:4px;background:var(--accent-2);box-shadow:0 0 0 3px rgba(124,110,246,.15)}
|
||||
.flog-dot.st-a{background:var(--add)}.flog-dot.st-d{background:var(--del)}.flog-dot.st-e{background:var(--edit)}
|
||||
.flog-line{flex:1;width:2px;background:var(--border);margin-top:2px}
|
||||
.flog-main{flex:1;min-width:0}
|
||||
.flog-head{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
|
||||
.flog-head .rev{font-weight:700;color:var(--txt);font-variant-numeric:tabular-nums}
|
||||
.flog-cl{color:var(--accent-2);font-variant-numeric:tabular-nums}
|
||||
.flog-user{color:var(--muted)}.flog-time{color:var(--faint);margin-left:auto;font-variant-numeric:tabular-nums}
|
||||
.flog-desc{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
/* blame + diff panes */
|
||||
.picker.blame{width:900px;height:640px}
|
||||
.blame-body,.diff-body{flex:1;overflow:auto;padding:8px 0;font-family:var(--mono);font-size:12px;line-height:1.5;background:var(--stage-bg)}
|
||||
@ -820,8 +847,37 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
|
||||
/* ---------------- People & Roles ---------------- */
|
||||
.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}
|
||||
.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-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)}
|
||||
|
||||
782
src/App.tsx
782
src/App.tsx
@ -7,7 +7,7 @@ import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import UpdateBanner from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor } 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 { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
||||
|
||||
@ -309,6 +309,17 @@ type ModalState =
|
||||
| { kind: "users" }
|
||||
| { kind: "search" }
|
||||
| { kind: "locks" }
|
||||
| { kind: "typemap" }
|
||||
| { kind: "labels" }
|
||||
| { kind: "branch" }
|
||||
| { kind: "streams" }
|
||||
| { kind: "jobs" }
|
||||
| { kind: "clean" }
|
||||
| { kind: "reconcile" }
|
||||
| { kind: "explorer" }
|
||||
| { kind: "ignore" }
|
||||
| { kind: "filelog"; depot: string; name: string }
|
||||
| { kind: "client"; name: string; isNew: boolean }
|
||||
| { kind: "blame"; spec: string; name: string }
|
||||
| { kind: "diff"; title: string; text: string }
|
||||
| { kind: "about" };
|
||||
@ -357,6 +368,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
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 lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
||||
const notifPrimed = useRef(false); // don't toast on the very first poll
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
|
||||
@ -635,6 +647,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
if (workOffline) return; // explicit offline-work mode: don't poll the server
|
||||
try {
|
||||
const latest = await p4.latestChange(activePath);
|
||||
if (!alive) return;
|
||||
@ -659,7 +672,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
tick();
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
// eslint-disable-next-line
|
||||
}, [activePath, offline, tab]);
|
||||
}, [activePath, offline, tab, workOffline]);
|
||||
|
||||
// drag & drop files/folders onto the window → reconcile them into the changelist
|
||||
useEffect(() => {
|
||||
@ -729,6 +742,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
async function doSwitchStream(stream: string) {
|
||||
setModal(null); setBusy(true);
|
||||
try { const i = await p4.switchStream(stream); onInfo(i); flash(t("Switched to stream {s}", { s: stream })); await refresh(); await loadHistory(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
async function doStreamInteg(stream: string, dir: "down" | "up") {
|
||||
setBusy(true);
|
||||
try { const out = await p4.streamInteg(stream, dir); flash(t("{d} opened into a pending changelist — resolve & submit. {out}", { d: dir === "up" ? t("Copy up") : t("Merge down"), out: out.split("\n")[0] })); await refresh(); }
|
||||
catch (e) { flash(String(e), true); } finally { setBusy(false); }
|
||||
}
|
||||
function chooseScope(path: string) {
|
||||
setModal(null);
|
||||
setActivePath(path);
|
||||
@ -757,6 +780,22 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
// sync the workspace to an exact changelist / label / date (Get Revision)
|
||||
async function syncTo(rev: string) {
|
||||
const r = rev.trim();
|
||||
if (!r) return;
|
||||
if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return;
|
||||
setBusy(true);
|
||||
try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: await p4.syncTo(activePath, r) })); await refresh(); await loadHistory(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
function promptSyncTo() {
|
||||
setPrompt({ title: t("Get Revision"), label: t("Changelist number, label name, or date (YYYY/MM/DD)"), value: "", onSave: (v) => { setPrompt(null); syncTo(v); } });
|
||||
}
|
||||
// create a new workspace: ask for a name, then open the spec editor with a template
|
||||
function newWorkspace() {
|
||||
setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
|
||||
}
|
||||
|
||||
// Manual re-scan (same background reconcile as auto, on demand).
|
||||
function rescan() { refresh(activePath, true); }
|
||||
@ -868,11 +907,31 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } // draft clears on successful Submit
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
function attachJob(change: string) {
|
||||
setPrompt({ title: t("Attach job to #{n}", { n: change }), label: t("Job ID"), value: "", onSave: async (v) => {
|
||||
setPrompt(null); const j = v.trim(); if (!j) return;
|
||||
try { await p4.fix(j, change); flash(t("Job {j} linked to #{n}.", { j, n: change })); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
} });
|
||||
}
|
||||
async function shelveCL(change: string) {
|
||||
setBusy(true);
|
||||
try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); }
|
||||
catch (e) { flash(String(e), true); } finally { setBusy(false); }
|
||||
}
|
||||
// submit variants: reopen after submit (-r), revert-unchanged, or submit a shelf (-e)
|
||||
async function submitWithOpts(change: string, reopen: boolean, revertUnchanged: boolean) {
|
||||
if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
|
||||
setBusy(true);
|
||||
try { await p4.submitOpts(change, reopen, revertUnchanged); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
async function submitShelved(change: string) {
|
||||
if (!(await confirm({ title: t("Submit shelved #{n}?", { n: change }), body: t("The shelved files of #{n} are submitted directly from the server.", { n: change }), confirm: t("Submit") }))) return;
|
||||
setBusy(true);
|
||||
try { await p4.submitShelved(change); flash(t("Submitted shelved changelist #{n}.", { n: change })); await refresh(); await loadHistory(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
async function unshelveCL(change: string) {
|
||||
setBusy(true);
|
||||
try { await p4.unshelve(change); flash(t("Unshelved #{n} into the workspace.", { n: change })); await refresh(); }
|
||||
@ -985,6 +1044,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
|
||||
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
|
||||
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
|
||||
{ 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) })),
|
||||
...(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 }) }] : []),
|
||||
@ -997,11 +1058,36 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try { await (lock ? p4.lock(dps) : p4.unlock(dps)); flash(lock ? t("Locked {n} file(s).", { n: dps.length }) : t("Unlocked {n} file(s).", { n: dps.length })); await refresh(); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
// switch opened files to an exclusive-lock file type (+l) so only one person edits them
|
||||
async function setExclusiveType(dps: string[]) {
|
||||
if (!dps.length) return;
|
||||
try { await p4.reopenType(dps, "+l"); flash(t("{n} file(s) set to exclusive-lock type (+l).", { n: dps.length })); await refresh(); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
async function moveToCL(change: string, dps: string[]) {
|
||||
if (!change || !dps.length) return;
|
||||
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); }
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
// 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)
|
||||
async function showDiffPrev(file: OpenedFile) {
|
||||
const dp = file.depotFile || "";
|
||||
@ -1024,18 +1110,51 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
} catch (e) { flash(String(e), true); }
|
||||
}
|
||||
|
||||
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => void }[]> = {
|
||||
File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }],
|
||||
Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }],
|
||||
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { 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() }],
|
||||
Window: [
|
||||
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") },
|
||||
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") },
|
||||
...(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" }) },
|
||||
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => void }[]> = {
|
||||
File: [
|
||||
{ label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
|
||||
{ label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
|
||||
...(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: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
|
||||
{ label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
|
||||
],
|
||||
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { 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" }) }],
|
||||
Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }],
|
||||
Connection: [
|
||||
{ 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: (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") }] : []),
|
||||
{ label: t("File Locks…"), icon: I.lock, hint: t("See every file currently exclusively locked and by whom."), act: () => setModal({ kind: "locks" }) },
|
||||
],
|
||||
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 (
|
||||
@ -1048,7 +1167,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{t(name)}
|
||||
<div className="dropdown">
|
||||
{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.label}{it.kb && <span className="kb">{it.kb}</span>}
|
||||
</div>
|
||||
@ -1102,9 +1221,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</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">
|
||||
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
|
||||
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
|
||||
</div>
|
||||
)}
|
||||
{needResolve.length > 0 && tab === "changes" && (
|
||||
@ -1146,8 +1272,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}>↑</button>
|
||||
<button className="cl-more" onClick={(e) => openCtx(e, [
|
||||
{ label: t("Edit description"), act: () => editDesc(cl) },
|
||||
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
|
||||
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
|
||||
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
|
||||
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
|
||||
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
|
||||
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
|
||||
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
|
||||
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
|
||||
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
|
||||
@ -1218,6 +1348,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
|
||||
onContext={(c, e) => openCtx(e, [
|
||||
{ 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("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
|
||||
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
|
||||
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
|
||||
@ -1226,7 +1357,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</div>
|
||||
|
||||
{tab === "changes" ? (
|
||||
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
|
||||
<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} />
|
||||
) : (
|
||||
<div className={"histsplit" + (histFile ? "" : " solo")}>
|
||||
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
|
||||
@ -1243,7 +1374,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
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} 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>
|
||||
)}
|
||||
@ -1286,6 +1417,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
|
||||
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
|
||||
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
|
||||
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{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 === "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 === "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 === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
|
||||
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
|
||||
@ -1967,6 +2109,609 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- .p4ignore editor ---------------- */
|
||||
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
|
||||
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.ignoreRead().then((s) => { if (live) { setText(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, []); // eslint-disable-line
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try { await p4.ignoreWrite(text); onFlash(t(".p4ignore saved.")); onClose(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.gear}<h3>.p4ignore</h3><span className="ph-sub">{t("at the workspace root")}</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("Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).")}
|
||||
{!busy && !text.trim() && <> <button className="ce-btn" style={{ display: "inline-flex", marginLeft: 6 }} onClick={() => setText(P4IGNORE_TEMPLATE)}>{t("Insert Unreal template")}</button></>}</div>
|
||||
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
|
||||
: <textarea className="code-edit" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} placeholder={"Binaries/\nIntermediate/\nSaved/\n*.tmp"} />}
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- clean workspace (make it match the depot) ---------------- */
|
||||
function CleanModal({ 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);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.cleanPreview(scope).then((f) => live && setFiles(f)).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
|
||||
async function apply() {
|
||||
setApplying(true);
|
||||
try { await p4.cleanApply(scope); onFlash(t("Workspace cleaned to match the depot.")); onDone(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setApplying(false); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.revert}<h3>{t("Clean workspace")}</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" style={{ color: "var(--del)" }}>{t("Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.")}</div>
|
||||
<div className="srch-list">
|
||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning…")}</div>}
|
||||
{!busy && files.length === 0 && <div className="ur-empty">{t("Workspace already matches the depot — nothing to clean.")}</div>}
|
||||
{files.map((f) => {
|
||||
const dp = f.depotFile || f.clientFile || "";
|
||||
const sp = splitPath(dp);
|
||||
const st = statusOf(f.action);
|
||||
return (
|
||||
<div key={dp} className="srch-row">
|
||||
<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 danger" onClick={apply} disabled={applying || busy || files.length === 0}>{applying ? <span className="ldr sm" /> : null}{t("Clean {n} file(s)", { n: files.length })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 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.
|
||||
function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function toggle() {
|
||||
if (!node.isDir) 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 ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={toggle} 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} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [side, setSide] = useState<"depot" | "workspace">("depot");
|
||||
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));
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, [side]); // 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="modal-back" onClick={onClose}>
|
||||
<div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
|
||||
<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>
|
||||
<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="exp-total"><span className="n">{rootLabel}</span><span className="sz">{humanSize(total)}{kids ? ` · ${t("{n} items", { n: kids.length })}` : ""}</span></div>
|
||||
<div className="tm-hint">{side === "depot"
|
||||
? t("Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.")
|
||||
: t("Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.")}</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} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 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 }) {
|
||||
const [revs, setRevs] = useState<FileRev[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.filelog(depot).then((v) => { if (live) { setRevs(filelogRevs(v)); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, [depot]); // eslint-disable-line
|
||||
async function diffPrev(rev: string) {
|
||||
const n = Number(rev);
|
||||
if (n <= 1) { onFlash(t("No earlier revision to compare."), true); return; }
|
||||
try { const text = await p4.diff2(`${depot}#${n - 1}`, `${depot}#${n}`); onShowDiff(`${name} #${n - 1} ↔ #${n}`, text || t("(files are identical)")); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.log}<h3>{t("File history")}</h3><span className="ph-sub">{name}</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="flog-body">
|
||||
{busy && <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>}
|
||||
{!busy && revs.length === 0 && <div className="ur-empty">{t("No history.")}</div>}
|
||||
{revs.map((r) => {
|
||||
const st = statusOf(r.action);
|
||||
return (
|
||||
<div key={r.rev} className="flog-row">
|
||||
<span className="flog-rail"><span className={"flog-dot " + st.cls} />{Number(r.rev) > 1 && <span className="flog-line" />}</span>
|
||||
<div className="flog-main">
|
||||
<div className="flog-head"><span className="rev">#{r.rev}</span><span className={"stat " + st.cls}>{st.ch}</span><span className="flog-cl">#{r.change}</span><span className="flog-user">{r.user}</span><span className="flog-time">{fmtTime(r.time)}</span></div>
|
||||
<div className="flog-desc">{(r.desc || "").trim() || t("(no description)")}</div>
|
||||
</div>
|
||||
{Number(r.rev) > 1 && <button className="rslv-act" onClick={() => diffPrev(r.rev)}>{t("Diff ↔ prev")}</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- jobs (list / create / edit) ---------------- */
|
||||
function JobsModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [jobs, setJobs] = useState<{ Job?: string; Status?: string; Description?: string; User?: string }[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [edit, setEdit] = useState<null | { spec: string; isNew: boolean }>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const load = () => { setBusy(true); p4.jobs().then(setJobs).catch((e) => { onFlash(String(e), true); setJobs([]); }).finally(() => setBusy(false)); };
|
||||
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && (edit ? setEdit(null) : onClose()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [edit]); // eslint-disable-line
|
||||
async function openEdit(job: string) {
|
||||
try { const spec = await p4.jobSpec(job); setEdit({ spec, isNew: !job }); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
async function saveEdit() {
|
||||
if (!edit) return;
|
||||
setSaving(true);
|
||||
try { await p4.jobSave(edit.spec); onFlash(t("Job saved.")); setEdit(null); load(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const s = q.trim().toLowerCase();
|
||||
const rows = jobs.filter((j) => !s || (j.Job || "").toLowerCase().includes(s) || (j.Description || "").toLowerCase().includes(s) || (j.User || "").toLowerCase().includes(s));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.log}<h3>{t("Jobs")}</h3><span className="ph-sub">{edit ? (edit.isNew ? t("New job") : t("Edit job")) : t("{n} jobs", { n: jobs.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>
|
||||
{edit ? (<>
|
||||
<div className="tm-hint">{t("Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.")}</div>
|
||||
<textarea className="code-edit" value={edit.spec} spellCheck={false} onChange={(e) => setEdit({ ...edit, spec: e.target.value })} />
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={() => setEdit(null)} disabled={saving}>{t("Back")}</button>
|
||||
<button className="mbtn primary" onClick={saveEdit} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||
</div>
|
||||
</>) : (<>
|
||||
<div className="rslv-bar">
|
||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter jobs…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<button className="mbtn primary" onClick={() => openEdit("")}>{t("New job")}</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">{t("No jobs.")}</div>}
|
||||
{rows.map((j) => (
|
||||
<div key={j.Job} className="srch-row" onClick={() => openEdit(j.Job || "")} style={{ cursor: "pointer" }}>
|
||||
<span className="srch-body"><span className="n">{j.Job} <span style={{ color: "var(--faint)", fontWeight: 400 }}>· {j.Status}</span></span><span className="p">{(j.Description || "").trim().slice(0, 120)}{j.User ? " — " + j.User : ""}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- streams (list / switch / merge down / copy up) ---------------- */
|
||||
function StreamsModal({ current, onClose, onFlash, onSwitch, onInteg }: { current: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSwitch: (s: string) => void; onInteg: (s: string, dir: "down" | "up") => void }) {
|
||||
const [streams, setStreams] = useState<{ Stream?: string; Name?: string; Type?: string; Parent?: string }[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.streams().then((s) => live && setStreams(s)).catch((e) => { if (live) { onFlash(String(e), true); setStreams([]); } }).finally(() => live && setBusy(false));
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, []); // eslint-disable-line
|
||||
const s = q.trim().toLowerCase();
|
||||
const rows = streams.filter((x) => !s || (x.Stream || "").toLowerCase().includes(s) || (x.Name || "").toLowerCase().includes(s));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.branch}<h3>{t("Streams")}</h3><span className="ph-sub">{current || t("(not on a stream)")}</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>
|
||||
{current && (
|
||||
<div className="rslv-bar">
|
||||
<span style={{ flex: 1, fontSize: 12, color: "var(--muted)" }}>{t("Current stream integration")}</span>
|
||||
<button className="mbtn ghost" onClick={() => onInteg(current, "down")}>{t("Merge down")}</button>
|
||||
<button className="mbtn ghost" onClick={() => onInteg(current, "up")}>{t("Copy up")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input placeholder={t("Filter streams…")} value={q} onChange={(e) => setQ(e.target.value)} /></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">{t("No streams (this depot may be classic, not stream-based).")}</div>}
|
||||
{rows.map((x) => {
|
||||
const st = x.Stream || "";
|
||||
const on = st === current;
|
||||
return (
|
||||
<div key={st} className={"srch-row" + (on ? " on" : "")}>
|
||||
<span className={"held" + (on ? " locked" : "")} style={{ marginRight: 4 }}>{I.branch}<span>{x.Type || ""}</span></span>
|
||||
<span className="srch-body"><span className="n">{x.Name || st}{on && " · " + t("current")}</span><span className="p">{st}{x.Parent && x.Parent !== "none" ? " ← " + x.Parent : ""}</span></span>
|
||||
{!on && <button className="rslv-act" onClick={() => onSwitch(st)}>{t("Switch")}</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- integrate / merge / copy between branches ---------------- */
|
||||
function BranchModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
|
||||
const [op, setOp] = useState<"merge" | "copy" | "integrate">("merge");
|
||||
const [source, setSource] = useState(scope ? scope + "/..." : "//depot/…/...");
|
||||
const [target, setTarget] = useState("//depot/…/...");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
|
||||
const DESC: Record<string, string> = {
|
||||
merge: t("Merge changes from source into target — you resolve conflicts, then submit."),
|
||||
copy: t("Copy source over target verbatim (no merge). Target becomes identical to source."),
|
||||
integrate: t("Classic integrate — open target files for integration from source."),
|
||||
};
|
||||
async function run() {
|
||||
if (!source.trim() || !target.trim()) { onFlash(t("Source and target are required."), true); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const out = await p4.branchOp(op, source.trim(), target.trim());
|
||||
onFlash(t("{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}", { op, out: out.split("\n")[0] }));
|
||||
onDone();
|
||||
} catch (e) { onFlash(String(e), true); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker" style={{ width: 560, maxWidth: "94%" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.branch}<h3>{t("Integrate / Merge / Copy")}</h3>
|
||||
<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="info-body">
|
||||
<div className="langsel" style={{ marginBottom: 12 }}>
|
||||
{(["merge", "copy", "integrate"] as const).map((o) => (
|
||||
<button key={o} className={"langbtn" + (op === o ? " on" : "")} onClick={() => setOp(o)}>
|
||||
<span className="lname">{o === "merge" ? t("Merge") : o === "copy" ? t("Copy") : t("Integrate")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tm-hint" style={{ border: "none", padding: "0 0 12px" }}>{DESC[op]}</div>
|
||||
<div className="info-row"><span className="rk">{t("Source")}</span><input className="kf-in mono" value={source} onChange={(e) => setSource(e.target.value)} placeholder="//depot/dev/..." /></div>
|
||||
<div className="info-row"><span className="rk">{t("Target")}</span><input className="kf-in mono" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="//depot/main/..." /></div>
|
||||
<div className="modal-actions" style={{ marginTop: 16 }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={busy}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={run} disabled={busy}>{busy ? <span className="ldr sm" /> : null}{t("Run {op}", { op: op === "merge" ? t("Merge") : op === "copy" ? t("Copy") : t("Integrate") })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- labels (list / create / sync-to) ---------------- */
|
||||
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
|
||||
const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const load = () => { setBusy(true); p4.labels().then(setLabels).catch((e) => { onFlash(String(e), true); setLabels([]); }).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
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
async function create() {
|
||||
const n = newName.trim();
|
||||
if (!n) return;
|
||||
setCreating(true);
|
||||
try { await p4.labelTag(n, scope); onFlash(t("Label “{c}” tagged at current head.", { c: n })); setNewName(""); load(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setCreating(false); }
|
||||
}
|
||||
const s = q.trim().toLowerCase();
|
||||
const rows = labels.filter((l) => !s || (l.label || "").toLowerCase().includes(s) || (l.Description || "").toLowerCase().includes(s));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.clock}<h3>{t("Labels")}</h3><span className="ph-sub">{t("{n} labels", { n: labels.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="rslv-bar">
|
||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter labels…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<input className="kf-in" style={{ maxWidth: 190 }} placeholder={t("New label name")} value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} />
|
||||
<button className="mbtn primary" disabled={creating || !newName.trim()} onClick={create}>{creating ? <span className="ldr sm" /> : null}{t("Tag current")}</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">{t("No labels.")}</div>}
|
||||
{rows.map((l) => (
|
||||
<div key={l.label} className="srch-row">
|
||||
<span className="held" style={{ marginRight: 4 }}>{I.clock}</span>
|
||||
<span className="srch-body"><span className="n">{l.label}</span><span className="p">{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""}</span></span>
|
||||
<button className="rslv-act" onClick={() => onSyncTo(l.label || "")}>{t("Sync to")}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- workspace (client) spec editor ---------------- */
|
||||
function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
|
||||
const [spec, setSpec] = useState("");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, [name]); // eslint-disable-line
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await p4.clientSave(spec);
|
||||
onFlash(isNew ? t("Workspace “{c}” created.", { c: name }) : t("Workspace “{c}” updated.", { c: name }));
|
||||
onSaved(name);
|
||||
} catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</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("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
|
||||
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
|
||||
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- typemap / exclusive-lock rules ---------------- */
|
||||
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
|
||||
// only one person can check them out at a time, preventing lost binary merges.
|
||||
const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
|
||||
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [entries, setEntries] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [add, setAdd] = useState("");
|
||||
const load = () => { setBusy(true); p4.typemapGet().then(setEntries).catch((e) => { onFlash(String(e), true); setEntries([]); }).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
|
||||
const patternOf = (e: string) => (e.split(/\s+/)[1] || "").toLowerCase();
|
||||
function addRecommended() {
|
||||
const have = new Set(entries.map(patternOf));
|
||||
const add: string[] = [];
|
||||
for (const ext of UE_LOCK_EXTS) {
|
||||
const pat = `//....${ext}`;
|
||||
if (!have.has(pat)) add.push(`binary+l ${pat}`);
|
||||
}
|
||||
if (!add.length) { onFlash(t("All recommended rules are already present."), false); return; }
|
||||
setEntries((cur) => [...cur, ...add]);
|
||||
}
|
||||
function addOne() {
|
||||
const v = add.trim();
|
||||
if (!v) return;
|
||||
// accept "binary+l //....ext" or just "//....ext" (defaults to binary+l)
|
||||
const entry = /\s/.test(v) ? v : `binary+l ${v}`;
|
||||
setEntries((cur) => [...cur, entry]);
|
||||
setAdd("");
|
||||
}
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try { await p4.typemapSet(entries); onFlash(t("Typemap saved.")); onClose(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.lock}<h3>{t("Exclusive Locks (typemap)")}</h3>
|
||||
<span className="ph-sub">{t("{n} rules", { n: entries.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("Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.")}</div>
|
||||
<div className="rslv-bar">
|
||||
<button className="mbtn primary" onClick={addRecommended}>{I.lock}{t("Add recommended Unreal rules")}</button>
|
||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Add rule, e.g. binary+l //....uasset")} value={add} onChange={(e) => setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} /></div>
|
||||
</div>
|
||||
<div className="srch-list">
|
||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
|
||||
{!busy && entries.length === 0 && <div className="ur-empty">{t("No typemap rules yet.")}</div>}
|
||||
{entries.map((e, i) => {
|
||||
const parts = e.split(/\s+/);
|
||||
const type = parts[0] || "";
|
||||
const pat = parts.slice(1).join(" ");
|
||||
const excl = /\+l/.test(type);
|
||||
return (
|
||||
<div key={i + e} className="srch-row">
|
||||
<span className={"held" + (excl ? " locked" : "")} style={{ marginRight: 4 }}>{excl ? I.lock : I.unlock}<span>{type}</span></span>
|
||||
<span className="srch-body"><span className="n" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{pat}</span></span>
|
||||
<button className="srch-act" title={t("Remove")} onClick={() => setEntries((cur) => cur.filter((_, j) => j !== i))}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={save} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save typemap")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- blame / annotate ---------------- */
|
||||
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
@ -2200,7 +2945,7 @@ function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle,
|
||||
}
|
||||
|
||||
/* ---------------- preview panel ---------------- */
|
||||
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; uproject?: string }) {
|
||||
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, onFilelog, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; onFilelog?: (depot: string, name: string) => void; uproject?: string }) {
|
||||
const [explain, setExplain] = useState<string | null>(null); // AI code summary
|
||||
const [explBusy, setExplBusy] = useState(false);
|
||||
const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile
|
||||
@ -2268,6 +3013,7 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif
|
||||
{hist && onDiffPrev && codeFile && (
|
||||
<span className="gbtn icon" title={t("Diff against the previous revision")} onClick={() => onDiffPrev(file)}>{I.clock}</span>
|
||||
)}
|
||||
{onFilelog && dp && <span className="gbtn icon" title={t("File history")} onClick={() => onFilelog(dp, name)}>{I.log}</span>}
|
||||
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
160
src/i18n.ts
160
src/i18n.ts
@ -392,8 +392,168 @@ const D: Record<string, Tr> = {
|
||||
"All open": { ru: "Все открытые", de: "Alle offenen", fr: "Tous ouverts", es: "Todos abiertos" },
|
||||
"No locked files.": { ru: "Залоченных файлов нет.", de: "Keine gesperrten Dateien.", fr: "Aucun fichier verrouillé.", es: "No hay archivos bloqueados." },
|
||||
"No files open by anyone.": { ru: "Ни у кого нет открытых файлов.", de: "Niemand hat Dateien geöffnet.", fr: "Personne n'a de fichiers ouverts.", es: "Nadie tiene archivos abiertos." },
|
||||
"Exclusive Locks (typemap)…": { ru: "Эксклюзивные локи (typemap)…", de: "Exklusive Sperren (typemap)…", fr: "Verrous exclusifs (typemap)…", es: "Bloqueos exclusivos (typemap)…" },
|
||||
"Exclusive Locks (typemap)": { ru: "Эксклюзивные локи (typemap)", de: "Exklusive Sperren (typemap)", fr: "Verrous exclusifs (typemap)", es: "Bloqueos exclusivos (typemap)" },
|
||||
"Set exclusive-lock type (+l)": { ru: "Задать тип с эксклюзивным локом (+l)", de: "Typ mit exklusiver Sperre setzen (+l)", fr: "Définir le type verrou exclusif (+l)", es: "Establecer tipo de bloqueo exclusivo (+l)" },
|
||||
"{n} file(s) set to exclusive-lock type (+l).": { ru: "Файлов с эксклюзивным локом (+l): {n}.", de: "{n} Datei(en) auf exklusive Sperre (+l) gesetzt.", fr: "{n} fichier(s) en verrou exclusif (+l).", es: "{n} archivo(s) en tipo de bloqueo exclusivo (+l)." },
|
||||
"{n} rules": { ru: "правил: {n}", de: "{n} Regeln", fr: "{n} règles", es: "{n} reglas" },
|
||||
"Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.": { ru: "Файлы под правилом «binary+l» — эксклюзивный чекаут: их может править только один человек за раз. Рекомендуется для бинарных ассетов Unreal. Смена typemap требует прав администратора.", de: "Dateien mit einer „binary+l“-Regel sind Exklusiv-Checkout: nur eine Person kann sie gleichzeitig bearbeiten. Empfohlen für binäre Unreal-Assets. Das Ändern der Typemap erfordert Adminrechte.", fr: "Les fichiers correspondant à une règle « binary+l » sont en extraction exclusive : une seule personne peut les modifier à la fois. Recommandé pour les assets binaires Unreal. Modifier la typemap nécessite des droits admin.", es: "Los archivos que coinciden con una regla «binary+l» son de extracción exclusiva: solo una persona puede editarlos a la vez. Recomendado para assets binarios de Unreal. Cambiar la typemap requiere permisos de administrador." },
|
||||
"Add recommended Unreal rules": { ru: "Добавить рекомендованные правила Unreal", de: "Empfohlene Unreal-Regeln hinzufügen", fr: "Ajouter les règles Unreal recommandées", es: "Añadir reglas recomendadas de Unreal" },
|
||||
"Add rule, e.g. binary+l //....uasset": { ru: "Добавить правило, напр. binary+l //....uasset", de: "Regel hinzufügen, z. B. binary+l //....uasset", fr: "Ajouter une règle, ex. binary+l //....uasset", es: "Añadir regla, p. ej. binary+l //....uasset" },
|
||||
"No typemap rules yet.": { ru: "Правил typemap пока нет.", de: "Noch keine Typemap-Regeln.", fr: "Aucune règle de typemap pour l'instant.", es: "Aún no hay reglas de typemap." },
|
||||
"All recommended rules are already present.": { ru: "Все рекомендованные правила уже есть.", de: "Alle empfohlenen Regeln sind bereits vorhanden.", fr: "Toutes les règles recommandées sont déjà présentes.", es: "Todas las reglas recomendadas ya están presentes." },
|
||||
"Typemap saved.": { ru: "Typemap сохранён.", de: "Typemap gespeichert.", fr: "Typemap enregistrée.", es: "Typemap guardada." },
|
||||
"Save typemap": { ru: "Сохранить typemap", de: "Typemap speichern", fr: "Enregistrer la typemap", es: "Guardar typemap" },
|
||||
"Remove": { ru: "Убрать", de: "Entfernen", fr: "Retirer", es: "Quitar" },
|
||||
"Get Revision…": { ru: "Взять ревизию…", de: "Revision holen…", fr: "Obtenir une révision…", es: "Obtener revisión…" },
|
||||
"Get Revision": { ru: "Взять ревизию", de: "Revision holen", fr: "Obtenir une révision", es: "Obtener revisión" },
|
||||
"Changelist number, label name, or date (YYYY/MM/DD)": { ru: "Номер changelist, имя метки или дата (ГГГГ/ММ/ДД)", de: "Changelist-Nummer, Label-Name oder Datum (JJJJ/MM/TT)", fr: "Numéro de changelist, nom de label ou date (AAAA/MM/JJ)", es: "Número de changelist, nombre de etiqueta o fecha (AAAA/MM/DD)" },
|
||||
"Sync workspace to {rev}?": { ru: "Синкнуть воркспейс на {rev}?", de: "Arbeitsbereich auf {rev} synchronisieren?", fr: "Synchroniser l'espace de travail sur {rev} ?", es: "¿Sincronizar el espacio de trabajo a {rev}?" },
|
||||
"Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.": { ru: "Синкнутые файлы изменятся под {rev}. Незасабмиченные (открытые) правки не тронутся. Get Latest вернёт на голову.", de: "Deine synchronisierten Dateien werden an {rev} angepasst. Nicht übermittelte (offene) Arbeit bleibt unberührt. Mit „Get Latest“ kehrst du zum Head zurück.", fr: "Vos fichiers synchronisés passent à {rev}. Le travail non soumis (ouvert) n'est pas touché. « Get Latest » vous ramène à la tête.", es: "Tus archivos sincronizados cambian a {rev}. El trabajo no enviado (abierto) no se toca. Con «Get Latest» vuelves a la cabeza." },
|
||||
"Synced to {rev}: {msg}": { ru: "Синкнуто на {rev}: {msg}", de: "Auf {rev} synchronisiert: {msg}", fr: "Synchronisé sur {rev} : {msg}", es: "Sincronizado a {rev}: {msg}" },
|
||||
"Sync workspace to #{n}": { ru: "Синкнуть воркспейс на #{n}", de: "Arbeitsbereich auf #{n} synchronisieren", fr: "Synchroniser l'espace de travail sur #{n}", es: "Sincronizar el espacio de trabajo a #{n}" },
|
||||
"New workspace…": { ru: "Новый воркспейс…", de: "Neuer Arbeitsbereich…", fr: "Nouvel espace de travail…", es: "Nuevo espacio de trabajo…" },
|
||||
"New workspace": { ru: "Новый воркспейс", de: "Neuer Arbeitsbereich", fr: "Nouvel espace de travail", es: "Nuevo espacio de trabajo" },
|
||||
"Edit current workspace…": { ru: "Редактировать текущий воркспейс…", de: "Aktuellen Arbeitsbereich bearbeiten…", fr: "Modifier l'espace de travail actuel…", es: "Editar el espacio de trabajo actual…" },
|
||||
"Edit workspace": { ru: "Редактирование воркспейса", de: "Arbeitsbereich bearbeiten", fr: "Modifier l'espace de travail", es: "Editar espacio de trabajo" },
|
||||
"Workspace (client) name": { ru: "Имя воркспейса (client)", de: "Arbeitsbereich- (Client-)Name", fr: "Nom de l'espace de travail (client)", es: "Nombre del espacio de trabajo (client)" },
|
||||
"Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.": { ru: "Отредактируй спеку воркспейса. Укажи Root — локальную папку, и настрой View (депо → воркспейс). Строки с # — комментарии.", de: "Bearbeite die Arbeitsbereich-Spezifikation. Setze Root auf einen lokalen Ordner und passe das View-Mapping (Depot → Arbeitsbereich) an. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec de l'espace de travail. Définissez Root sur un dossier local et ajustez le mappage View (dépôt → espace de travail). Les lignes commençant par # sont des commentaires.", es: "Edita la especificación del espacio de trabajo. Establece Root en una carpeta local y ajusta el mapeo View (depósito → espacio de trabajo). Las líneas que empiezan con # son comentarios." },
|
||||
"Workspace “{c}” created.": { ru: "Воркспейс «{c}» создан.", de: "Arbeitsbereich „{c}“ erstellt.", fr: "Espace de travail « {c} » créé.", es: "Espacio de trabajo «{c}» creado." },
|
||||
"Workspace “{c}” updated.": { ru: "Воркспейс «{c}» обновлён.", de: "Arbeitsbereich „{c}“ aktualisiert.", fr: "Espace de travail « {c} » mis à jour.", es: "Espacio de trabajo «{c}» actualizado." },
|
||||
"Create & switch": { ru: "Создать и переключиться", de: "Erstellen & wechseln", fr: "Créer et basculer", es: "Crear y cambiar" },
|
||||
"Labels…": { ru: "Метки…", de: "Labels…", fr: "Labels…", es: "Etiquetas…" },
|
||||
"Labels": { ru: "Метки", de: "Labels", fr: "Labels", es: "Etiquetas" },
|
||||
"{n} labels": { ru: "меток: {n}", de: "{n} Labels", fr: "{n} labels", es: "{n} etiquetas" },
|
||||
"Filter labels…": { ru: "Фильтр меток…", de: "Labels filtern…", fr: "Filtrer les labels…", es: "Filtrar etiquetas…" },
|
||||
"New label name": { ru: "Имя новой метки", de: "Neuer Label-Name", fr: "Nom du nouveau label", es: "Nombre de nueva etiqueta" },
|
||||
"Tag current": { ru: "Отметить текущее", de: "Aktuelles taggen", fr: "Étiqueter l'actuel", es: "Etiquetar actual" },
|
||||
"Label “{c}” tagged at current head.": { ru: "Метка «{c}» проставлена на текущую голову.", de: "Label „{c}“ am aktuellen Head getaggt.", fr: "Label « {c} » posé sur la tête actuelle.", es: "Etiqueta «{c}» marcada en la cabeza actual." },
|
||||
"No labels.": { ru: "Меток нет.", de: "Keine Labels.", fr: "Aucun label.", es: "Sin etiquetas." },
|
||||
"Sync to": { ru: "Синкнуть на", de: "Sync auf", fr: "Synchroniser sur", es: "Sincronizar a" },
|
||||
"Integrate / Merge / Copy…": { ru: "Интеграция / Merge / Copy…", de: "Integrate / Merge / Copy…", fr: "Intégrer / Fusionner / Copier…", es: "Integrar / Fusionar / Copiar…" },
|
||||
"Integrate / Merge / Copy": { ru: "Интеграция / Merge / Copy", de: "Integrate / Merge / Copy", fr: "Intégrer / Fusionner / Copier", es: "Integrar / Fusionar / Copiar" },
|
||||
"Integrate": { ru: "Integrate", de: "Integrieren", fr: "Intégrer", es: "Integrar" },
|
||||
"Merge changes from source into target — you resolve conflicts, then submit.": { ru: "Слить изменения из source в target — ты разрешаешь конфликты и сабмитишь.", de: "Änderungen von Quelle in Ziel zusammenführen — du löst Konflikte und übermittelst.", fr: "Fusionner les changements de la source vers la cible — vous résolvez les conflits puis soumettez.", es: "Fusiona los cambios del origen al destino — resuelves conflictos y envías." },
|
||||
"Copy source over target verbatim (no merge). Target becomes identical to source.": { ru: "Скопировать source поверх target как есть (без merge). Target станет идентичен source.", de: "Quelle unverändert über Ziel kopieren (kein Merge). Ziel wird identisch zur Quelle.", fr: "Copier la source sur la cible telle quelle (sans fusion). La cible devient identique à la source.", es: "Copia el origen sobre el destino tal cual (sin fusión). El destino queda idéntico al origen." },
|
||||
"Classic integrate — open target files for integration from source.": { ru: "Классический integrate — открыть файлы target для интеграции из source.", de: "Klassisches Integrate — Zieldateien zur Integration aus der Quelle öffnen.", fr: "Intégration classique — ouvrir les fichiers cibles pour intégration depuis la source.", es: "Integración clásica — abrir los archivos de destino para integrar desde el origen." },
|
||||
"Target": { ru: "Цель", de: "Ziel", fr: "Cible", es: "Destino" },
|
||||
"Source and target are required.": { ru: "Нужны источник и цель.", de: "Quelle und Ziel erforderlich.", fr: "Source et cible requises.", es: "Se requieren origen y destino." },
|
||||
"{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}": { ru: "{op}: файлы открыты в pending-changelist. Разреши (если нужно) и сабмить. {out}", de: "{op} in einen ausstehenden Changelist geöffnet. Auflösen (falls nötig) und übermitteln. {out}", fr: "{op} ouvert dans un changelist en attente. Résolvez (si besoin) et soumettez. {out}", es: "{op} abierto en un changelist pendiente. Resuelve (si hace falta) y envía. {out}" },
|
||||
"Run {op}": { ru: "Запустить {op}", de: "{op} ausführen", fr: "Lancer {op}", es: "Ejecutar {op}" },
|
||||
"Streams…": { ru: "Стримы…", de: "Streams…", fr: "Streams…", es: "Streams…" },
|
||||
"Streams": { ru: "Стримы", de: "Streams", fr: "Streams", es: "Streams" },
|
||||
"(not on a stream)": { ru: "(не на стриме)", de: "(nicht auf einem Stream)", fr: "(pas sur un stream)", es: "(sin stream)" },
|
||||
"Current stream integration": { ru: "Интеграция текущего стрима", de: "Integration des aktuellen Streams", fr: "Intégration du stream actuel", es: "Integración del stream actual" },
|
||||
"Merge down": { ru: "Merge down (из родителя)", de: "Merge down (vom Parent)", fr: "Merge down (depuis le parent)", es: "Merge down (desde el padre)" },
|
||||
"Copy up": { ru: "Copy up (в родителя)", de: "Copy up (zum Parent)", fr: "Copy up (vers le parent)", es: "Copy up (al padre)" },
|
||||
"Filter streams…": { ru: "Фильтр стримов…", de: "Streams filtern…", fr: "Filtrer les streams…", es: "Filtrar streams…" },
|
||||
"No streams (this depot may be classic, not stream-based).": { ru: "Стримов нет (депо может быть классическим, не на стримах).", de: "Keine Streams (dieses Depot ist evtl. klassisch, nicht stream-basiert).", fr: "Aucun stream (ce dépôt est peut-être classique, pas basé sur des streams).", es: "Sin streams (este depósito puede ser clásico, no basado en streams)." },
|
||||
"current": { ru: "текущий", de: "aktuell", fr: "actuel", es: "actual" },
|
||||
"Switch": { ru: "Переключить", de: "Wechseln", fr: "Basculer", es: "Cambiar" },
|
||||
"Switched to stream {s}": { ru: "Переключено на стрим {s}", de: "Zu Stream {s} gewechselt", fr: "Basculé sur le stream {s}", es: "Cambiado al stream {s}" },
|
||||
"{d} opened into a pending changelist — resolve & submit. {out}": { ru: "{d}: файлы открыты в pending-changelist — разреши и сабмить. {out}", de: "{d} in einen ausstehenden Changelist geöffnet — auflösen & übermitteln. {out}", fr: "{d} ouvert dans un changelist en attente — résolvez et soumettez. {out}", es: "{d} abierto en un changelist pendiente — resuelve y envía. {out}" },
|
||||
"Jobs…": { ru: "Задачи (jobs)…", de: "Jobs…", fr: "Jobs…", es: "Jobs…" },
|
||||
"Jobs": { ru: "Задачи (jobs)", de: "Jobs", fr: "Jobs", es: "Jobs" },
|
||||
"{n} jobs": { ru: "задач: {n}", de: "{n} Jobs", fr: "{n} jobs", es: "{n} jobs" },
|
||||
"Filter jobs…": { ru: "Фильтр задач…", de: "Jobs filtern…", fr: "Filtrer les jobs…", es: "Filtrar jobs…" },
|
||||
"New job": { ru: "Новая задача", de: "Neuer Job", fr: "Nouveau job", es: "Nuevo job" },
|
||||
"Edit job": { ru: "Редактирование задачи", de: "Job bearbeiten", fr: "Modifier le job", es: "Editar job" },
|
||||
"Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.": { ru: "Отредактируй спеку задачи. Поля зависят от jobspec сервера. Строки с # — комментарии.", de: "Bearbeite die Job-Spezifikation. Die Felder hängen vom Jobspec des Servers ab. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec du job. Les champs dépendent du jobspec du serveur. Les lignes avec # sont des commentaires.", es: "Edita la especificación del job. Los campos dependen del jobspec del servidor. Las líneas con # son comentarios." },
|
||||
"Job saved.": { ru: "Задача сохранена.", de: "Job gespeichert.", fr: "Job enregistré.", es: "Job guardado." },
|
||||
"No jobs.": { ru: "Задач нет.", de: "Keine Jobs.", fr: "Aucun job.", es: "Sin jobs." },
|
||||
"Back": { ru: "Назад", de: "Zurück", fr: "Retour", es: "Atrás" },
|
||||
"Attach job…": { ru: "Привязать задачу…", de: "Job anhängen…", fr: "Associer un job…", es: "Adjuntar job…" },
|
||||
"Attach job to #{n}": { ru: "Привязать задачу к #{n}", de: "Job an #{n} anhängen", fr: "Associer un job à #{n}", es: "Adjuntar job a #{n}" },
|
||||
"Job ID": { ru: "ID задачи", de: "Job-ID", fr: "ID du job", es: "ID del job" },
|
||||
"Job {j} linked to #{n}.": { ru: "Задача {j} привязана к #{n}.", de: "Job {j} mit #{n} verknüpft.", fr: "Job {j} associé à #{n}.", es: "Job {j} vinculado a #{n}." },
|
||||
"File history": { ru: "История файла", de: "Dateiverlauf", fr: "Historique du fichier", es: "Historial del archivo" },
|
||||
"No history.": { ru: "Истории нет.", de: "Kein Verlauf.", fr: "Aucun historique.", es: "Sin historial." },
|
||||
"Diff ↔ prev": { ru: "Diff ↔ пред.", de: "Diff ↔ vorher", fr: "Diff ↔ préc.", es: "Diff ↔ ant." },
|
||||
"Clean workspace…": { ru: "Очистить воркспейс…", de: "Arbeitsbereich bereinigen…", fr: "Nettoyer l'espace de travail…", es: "Limpiar espacio de trabajo…" },
|
||||
"Clean workspace": { ru: "Очистка воркспейса", de: "Arbeitsbereich bereinigen", fr: "Nettoyer l'espace de travail", es: "Limpiar espacio de trabajo" },
|
||||
"{n} changes": { ru: "изменений: {n}", de: "{n} Änderungen", fr: "{n} changements", es: "{n} cambios" },
|
||||
"Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.": { ru: "Clean приводит воркспейс в точное соответствие депо: лишние локальные файлы удаляются, удалённые локально — восстанавливаются, неоткрытые локальные правки откатываются. Открытые (взятые на редактирование) файлы НЕ трогаются. Отменить нельзя.", de: "Clean bringt den Arbeitsbereich exakt in Übereinstimmung mit dem Depot: zusätzliche lokale Dateien werden gelöscht, lokal gelöschte wiederhergestellt und nicht geöffnete lokale Änderungen zurückgesetzt. Geöffnete (ausgecheckte) Dateien bleiben unberührt. Nicht widerrufbar.", fr: "Clean aligne exactement l'espace de travail sur le dépôt : les fichiers locaux supplémentaires sont supprimés, les fichiers supprimés localement restaurés et les modifications locales non ouvertes annulées. Les fichiers ouverts (extraits) ne sont PAS touchés. Irréversible.", es: "Clean hace que el espacio de trabajo coincida exactamente con el depósito: los archivos locales extra se eliminan, los borrados localmente se restauran y las ediciones locales no abiertas se revierten. Los archivos abiertos (extraídos) NO se tocan. No se puede deshacer." },
|
||||
"Workspace already matches the depot — nothing to clean.": { ru: "Воркспейс уже соответствует депо — чистить нечего.", de: "Arbeitsbereich stimmt bereits mit dem Depot überein — nichts zu bereinigen.", fr: "L'espace de travail correspond déjà au dépôt — rien à nettoyer.", es: "El espacio de trabajo ya coincide con el depósito — nada que limpiar." },
|
||||
"Workspace cleaned to match the depot.": { ru: "Воркспейс очищен под депо.", de: "Arbeitsbereich an das Depot angeglichen.", fr: "Espace de travail nettoyé pour correspondre au dépôt.", es: "Espacio de trabajo limpiado para coincidir con el depósito." },
|
||||
"Clean {n} file(s)": { ru: "Очистить файлов: {n}", de: "{n} Datei(en) bereinigen", fr: "Nettoyer {n} fichier(s)", es: "Limpiar {n} archivo(s)" },
|
||||
"Submit & keep checked out (-r)": { ru: "Сабмит и оставить открытым (-r)", de: "Übermitteln & ausgecheckt lassen (-r)", fr: "Soumettre et garder extrait (-r)", es: "Enviar y mantener extraído (-r)" },
|
||||
"Submit, reverting unchanged": { ru: "Сабмит, откатив неизменённые", de: "Übermitteln, Unveränderte zurücksetzen", fr: "Soumettre, annuler les inchangés", es: "Enviar, revirtiendo los sin cambios" },
|
||||
"Submit shelved (-e)": { ru: "Сабмит из shelve (-e)", de: "Geshelvtes übermitteln (-e)", fr: "Soumettre le remisé (-e)", es: "Enviar lo archivado (-e)" },
|
||||
"Submit shelved #{n}?": { ru: "Сабмитить отложенное #{n}?", de: "Geshelvtes #{n} übermitteln?", fr: "Soumettre le remisé #{n} ?", es: "¿Enviar lo archivado #{n}?" },
|
||||
"The shelved files of #{n} are submitted directly from the server.": { ru: "Отложенные файлы #{n} сабмитятся напрямую с сервера.", de: "Die geshelvten Dateien von #{n} werden direkt vom Server übermittelt.", fr: "Les fichiers remisés de #{n} sont soumis directement depuis le serveur.", es: "Los archivos archivados de #{n} se envían directamente desde el servidor." },
|
||||
"Submitted shelved changelist #{n}.": { ru: "Отправлено отложенное #{n}.", de: "Geshelvter Changelist #{n} übermittelt.", fr: "Changelist remisé #{n} soumis.", es: "Changelist archivado #{n} enviado." },
|
||||
"Edit .p4ignore…": { ru: "Редактировать .p4ignore…", de: ".p4ignore bearbeiten…", fr: "Modifier .p4ignore…", es: "Editar .p4ignore…" },
|
||||
"at the workspace root": { ru: "в корне воркспейса", de: "im Arbeitsbereich-Root", fr: "à la racine de l'espace de travail", es: "en la raíz del espacio de trabajo" },
|
||||
"Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).": { ru: "Файлы под этими паттернами игнорируются при reconcile / add. По одному паттерну на строку. Нужна настроенная переменная P4IGNORE (обычно .p4ignore).", de: "Dateien, die diesen Mustern entsprechen, werden von reconcile / add ignoriert. Ein Muster pro Zeile. Erfordert konfiguriertes P4IGNORE (meist .p4ignore).", fr: "Les fichiers correspondant à ces motifs sont ignorés par reconcile / add. Un motif par ligne. Nécessite P4IGNORE configuré (généralement .p4ignore).", es: "Los archivos que coinciden con estos patrones se ignoran en reconcile / add. Un patrón por línea. Requiere P4IGNORE configurado (normalmente .p4ignore)." },
|
||||
"Insert Unreal template": { ru: "Вставить шаблон Unreal", de: "Unreal-Vorlage einfügen", fr: "Insérer le modèle Unreal", es: "Insertar plantilla de Unreal" },
|
||||
".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." },
|
||||
"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." },
|
||||
};
|
||||
|
||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||
|
||||
59
src/p4.ts
59
src/p4.ts
@ -64,6 +64,25 @@ export const p4 = {
|
||||
history: (path: string) => invoke<Change[]>("p4_history", { path }),
|
||||
disconnect: () => invoke<void>("p4_disconnect"),
|
||||
sync: (scope = "") => invoke<string>("p4_sync", { scope }),
|
||||
syncTo: (scope: string, rev: string) => invoke<string>("p4_sync_to", { scope, rev }),
|
||||
labels: () => invoke<{ label?: string; Update?: string; Owner?: string; Description?: string; [k: string]: unknown }[]>("p4_labels"),
|
||||
labelTag: (label: string, scope: string, rev = "") => invoke<string>("p4_label_tag", { label, scope, rev }),
|
||||
branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke<string>("p4_branch_op", { op, source, target }),
|
||||
streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"),
|
||||
switchStream: (stream: string) => invoke<P4Info>("p4_switch_stream", { stream }),
|
||||
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
|
||||
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
|
||||
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
|
||||
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
|
||||
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
|
||||
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"),
|
||||
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
|
||||
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
|
||||
jobSave: (spec: string) => invoke<string>("p4_job_save", { spec }),
|
||||
diff: (file: string) => invoke<string>("p4_diff", { file }),
|
||||
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
|
||||
submit: (description: string, files: string[]) =>
|
||||
@ -71,6 +90,10 @@ export const p4 = {
|
||||
commit: (description: string, files: string[]) =>
|
||||
invoke<string>("p4_commit", { description, files }),
|
||||
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
|
||||
submitShelved: (change: string) => invoke<string>("p4_submit_shelved", { change }),
|
||||
submitOpts: (change: string, reopen: boolean, revertUnchanged: boolean) => invoke<string>("p4_submit_opts", { change, reopen, revertUnchanged }),
|
||||
ignoreRead: () => invoke<string>("p4_ignore_read"),
|
||||
ignoreWrite: (content: string) => invoke<void>("p4_ignore_write", { content }),
|
||||
reopenDefault: (files: string[]) => invoke<string>("p4_reopen_default", { files }),
|
||||
setDesc: (change: string, description: string) => invoke<string>("p4_set_desc", { change, description }),
|
||||
add: (files: string[]) => invoke<OpenedFile[]>("p4_add", { files }),
|
||||
@ -105,6 +128,8 @@ export const p4 = {
|
||||
// built-in editor: write text back to a depot file's working copy
|
||||
writeDepot: (depot: string, content: string) => invoke<void>("write_depot", { depot, content }),
|
||||
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
|
||||
clientSpec: (client: string) => invoke<string>("p4_client_spec", { client }),
|
||||
clientSave: (spec: string) => invoke<string>("p4_client_save", { spec }),
|
||||
users: () => invoke<User[]>("p4_users"),
|
||||
groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }),
|
||||
groupMember: (group: string, user: string, add: boolean) => invoke<string>("p4_group_member", { group, user, add }),
|
||||
@ -118,6 +143,9 @@ export const p4 = {
|
||||
openedAll: (scope = "") => invoke<OpenedFile[]>("p4_opened_all", { scope }),
|
||||
lock: (files: string[]) => invoke<string>("p4_lock", { files }),
|
||||
unlock: (files: string[]) => invoke<string>("p4_unlock", { files }),
|
||||
typemapGet: () => invoke<string[]>("p4_typemap_get"),
|
||||
typemapSet: (entries: string[]) => invoke<string>("p4_typemap_set", { entries }),
|
||||
reopenType: (files: string[], filetype: string) => invoke<string>("p4_reopen_type", { files, filetype }),
|
||||
shelve: (change: string) => invoke<string>("p4_shelve", { change }),
|
||||
unshelve: (change: string) => invoke<string>("p4_unshelve", { change }),
|
||||
deleteShelf: (change: string) => invoke<string>("p4_delete_shelf", { change }),
|
||||
@ -163,6 +191,37 @@ export function leaf(path?: string): string {
|
||||
return p.slice(p.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
// 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 }
|
||||
/** 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[] {
|
||||
const out: FileRev[] = [];
|
||||
for (let i = 0; ; i++) {
|
||||
const rev = v[`rev${i}`] as string | undefined;
|
||||
if (rev == null) break;
|
||||
out.push({
|
||||
rev,
|
||||
change: (v[`change${i}`] as string) || "",
|
||||
action: (v[`action${i}`] as string) || "",
|
||||
user: (v[`user${i}`] as string) || "",
|
||||
time: (v[`time${i}`] as string) || "",
|
||||
desc: (v[`desc${i}`] as string) || "",
|
||||
type: (v[`type${i}`] as string) || "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// a submitted changelist with indexed file fields (depotFile0, action0, ...)
|
||||
export interface Describe extends Change { [k: string]: unknown }
|
||||
export function describeFiles(d: Describe): { depotFile: string; action: string; rev: string }[] {
|
||||
|
||||
Reference in New Issue
Block a user