14 Commits

Author SHA1 Message Date
8e2cac4437 Release 0.2.2 — browse submitted changelists, People & Roles, AI summaries
- History: GitHub-style split view of submitted changelists; preview file
  contents at their exact revision (code/image/3D/uasset) via `p4 print`
- People & Roles modal: who works on the depot, activity indicators,
  group/role assignment (p4 users / groups / group -i)
- AI commit summaries via OpenRouter (sparkle button); key/model in Settings
- Commit-message draft persists until Submit (not cleared on local commit)
- Resizable History file-list column (pointer-capture drag)
- Fix UI-scale leaving a gap at the bottom (compensate zoom in .win height)
- Author avatars on history rows; bump version to 0.2.2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:18:07 +03:00
168b6afab3 Unreal build configurations (Development / Shipping / …)
Build Solution now opens a configuration picker (Development Editor,
Development, DebugGame, Shipping) and passes /p:Configuration=<cfg>
/p:Platform=Win64 to MSBuild, which drives UnrealBuildTool for the chosen
config. Streams into the same minimizable build overlay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:40:01 +03:00
de6d55ad84 Add Pending changelists view (option B)
The Changes tab now shows committed-but-not-submitted changelists as cards
above the working set: expandable to their files (click to preview), each
with per-CL Submit, plus Edit description / Uncommit (reopen -c default) /
Discard in a ⋯ menu, and a Submit-all button. Backend adds p4_reopen_default
and p4_set_desc; a small text-prompt modal edits descriptions. Preview panel
now shows either the working selection or a clicked pending-CL file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:34:59 +03:00
1675a7cd49 Add branded app icon
Exbyte Depot icon: white cube glyph on a violet gradient rounded square
(src-tauri/app-icon.svg), regenerated all platform icons via tauri icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:26:54 +03:00
0d04c8b2a4 Release v0.2.1
Bump version to 0.2.1 and publish signed installer + updated latest.json to
Gitea. Installed 0.2.0 clients will see the update banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:24:39 +03:00
afc4824b26 Fix build success detection + minimizable build window + de-dupe event logs
- build_sln now trusts UnrealBuildTool's "Result: Succeeded" (and absence of
  real compile errors) over MSBuild's exit code, which is non-zero for a side
  project even when the UE game module built fine.
- Fixed double event-listener registration (async unlisten under StrictMode)
  that duplicated every p4-log / p4-transfer / build-log line.
- Build overlay can be minimized to a small floating chip (spinner while
  building, ✓/✗ when done), click to expand, × to dismiss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:16:03 +03:00
b00b4f94b0 Add Build Solution (MSBuild) with live output
find_sln locates a .sln in the working folder; build_sln locates MSBuild
via vswhere and builds the solution's default config (compiles the UE game
module for Unreal projects), streaming output as build-log events. A build
overlay shows the live log with error/warning coloring and a status. Wired
into Tools (Ctrl+B) and the Working-folder right-click menu when a .sln is
found. New strings translated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:04:03 +03:00
cd61de548d Add Discard changes (revert) via right-click on Changes rows
Right-clicking a file in Changes opens a menu with "Discard changes"
(p4 revert, confirmed, danger) plus Edit in VS Code / Open in Explorer /
Copy path. Operates on the whole multi-selection when the row is part of
it; right-clicking an unselected row selects it first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:57:51 +03:00
670acf5242 Multi-select in the Changes list (Shift/Ctrl-click)
Rows now support Shift-click (range) and Ctrl-click (toggle) selection on
top of the single primary row used for the preview. Toggling a checkbox
while a multi-selection is active checks/unchecks the whole selection.
Accent bar marks the primary row; text selection disabled on the rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:55:05 +03:00
72b163f592 Add "Edit in VS Code" button to the code preview
Code/text files get a VS Code action in the preview header; a backend
open_in_vscode command resolves the depot file to its local working copy
and launches `code <path>` via the shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:51:06 +03:00
e9868c5b2e Add GitHub-style code preview + UE icon in context menu
- New CodeView: syntax-highlighted source (highlight.js, bundled offline)
  with line numbers for new files, colored unified diff for edits. Covers
  cpp/c/h, cs, py, js/ts, json/uproject, xml, ini, glsl. hljs tokens mapped
  to the app palette so it's theme-aware. Replaces the bare DiffView.
- Context-menu items now support icons; the Launch Unreal Engine item shows
  the UE mark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:45:03 +03:00
2feb7ca5e1 Fix UE launch permission, move it to context menu, refresh History after Submit
- Launch now goes through a backend launch_file command (shell `start`), so
  it works for any path — fixes "Not allowed to open path" from the opener
  plugin's scoped open-path.
- Removed the toolbar Launch-Unreal button; it's now a right-click item on the
  Working folder ("Launch Unreal Engine"), shown only when a .uproject exists.
- Reload History after a successful Submit so the new changelist shows without
  a manual F5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:33:34 +03:00
d9f99bc58b Custom workspace dropdown + Launch-Unreal button
Replace the ugly native <select> on the connect screen with a themed
custom dropdown (Select component). Add a Launch-Unreal button that appears
when the chosen working folder contains a .uproject: backend find_uproject
resolves the scope to disk and searches it (and one level down); the button
opens the project via the opener plugin. Bundled the UE icon (currentColor,
so white-on-dark / black-on-light per theme). New strings translated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:28:17 +03:00
4249d5ba66 Release v0.2.0
Bump version to 0.2.0 across package.json, tauri.conf.json and Cargo.toml.
First signed release published to Gitea (tags v0.2.0 + updater/latest.json);
auto-updater endpoint verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:13:45 +03:00
67 changed files with 1625 additions and 107 deletions

14
package-lock.json generated
View File

@ -1,18 +1,19 @@
{
"name": "exbyte-depot",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "exbyte-depot",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"highlight.js": "^11.11.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.185.1"
@ -1820,6 +1821,15 @@
"node": ">=6.9.0"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.1.0",
"version": "0.2.2",
"type": "module",
"scripts": {
"dev": "vite",
@ -15,6 +15,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"highlight.js": "^11.11.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.185.1"

2
src-tauri/Cargo.lock generated
View File

@ -951,7 +951,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.1.0"
version = "0.2.2"
dependencies = [
"serde",
"serde_json",

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.1.0"
version = "0.2.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

21
src-tauri/app-icon.svg Normal file
View File

@ -0,0 +1,21 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#9a8bf9"/>
<stop offset="1" stop-color="#5b49e6"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.36" r="0.72">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.30"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#bg)"/>
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#glow)"/>
<g transform="translate(232,236) scale(23.3)" fill="none" stroke="#ffffff" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z"/>
<path d="m3 7 9 5 9-5M12 12v10"/>
</g>
<g transform="translate(232,236) scale(23.3)">
<path d="M12 2 3 7l9 5 9-5-9-5Z" fill="#ffffff" fill-opacity="0.16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@ -6,6 +6,7 @@
"permissions": [
"core:default",
"opener:default",
"opener:allow-open-path",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -6,7 +6,7 @@ use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
/// App handle stashed at startup so the low-level p4 helpers can emit a live
/// command log to the frontend (a Perforce-style "Log" panel).
@ -480,6 +480,69 @@ async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<
run_stream(&conn, &["submit", "-c", &change], "submit")
}
/// "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]
async fn p4_reopen_default(state: State<'_, AppState>, files: Vec<String>) -> Result<String, String> {
let conn = current(&state)?;
if files.is_empty() {
return Ok(String::new());
}
let mut args: Vec<&str> = vec!["reopen", "-c", "default"];
for f in &files {
args.push(f.as_str());
}
run_text(&conn, &args)
}
/// Edit the description of a pending changelist, preserving its file list.
#[tauri::command]
async fn p4_set_desc(state: State<'_, AppState>, change: String, description: String) -> Result<String, String> {
let conn = current(&state)?;
let spec = run_text(&conn, &["change", "-o", &change])?;
// rebuild the spec, replacing only the Description field's indented body
let mut out = String::new();
let mut lines = spec.lines().peekable();
let mut replaced = false;
while let Some(line) = lines.next() {
if !replaced && line.starts_with("Description:") {
out.push_str("Description:\n");
for dl in description.trim().lines() {
out.push('\t');
out.push_str(dl);
out.push('\n');
}
// consume the old (indented / blank) description body
while let Some(p) = lines.peek() {
if p.starts_with('\t') || p.starts_with(' ') || p.is_empty() {
lines.next();
} else {
break;
}
}
replaced = true;
} else {
out.push_str(line);
out.push('\n');
}
}
let mut child = p4_cmd(&conn, false)
.args(["change", "-i"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start p4: {e}"))?;
if let Some(si) = child.stdin.as_mut() {
si.write_all(out.as_bytes()).map_err(|e| e.to_string())?;
}
let res = child.wait_with_output().map_err(|e| e.to_string())?;
if !res.status.success() {
return Err(String::from_utf8_lossy(&res.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&res.stdout).trim().to_string())
}
/// Submit the selected files with a description.
#[tauri::command]
async fn p4_submit(
@ -610,6 +673,224 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
Ok(())
}
/// Open a depot file's local working copy in VS Code (`code <path>`).
/// `code` is a .cmd on Windows, so it must be launched through the shell.
#[tauri::command]
async fn open_in_vscode(state: State<'_, AppState>, depot: String) -> Result<(), String> {
let conn = current(&state)?;
let local = run_json(&conn, &["where", &depot])?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
.ok_or_else(|| "Could not resolve the file path".to_string())?;
if !std::path::Path::new(&local).exists() {
return Err(format!("File is not on disk yet (not synced): {local}"));
}
let mut c = Command::new("cmd");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
// pass args separately so Rust quotes only the path (no double-escaping through cmd)
c.args(["/C", "code", &local]);
c.spawn()
.map_err(|e| format!("VS Code not found (is `code` in PATH?): {e}"))?;
Ok(())
}
/// Open a local file with its associated application (e.g. launch Unreal for a
/// `.uproject`). Uses the shell `start` so there are no path-scope restrictions.
#[tauri::command]
async fn launch_file(path: String) -> Result<(), String> {
if !std::path::Path::new(&path).exists() {
return Err(format!("File not found: {path}"));
}
let mut c = Command::new("cmd");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
c.args(["/C", "start", "", &path])
.spawn()
.map_err(|e| format!("Could not launch: {e}"))?;
Ok(())
}
/// Resolve a working-folder scope to a local directory via `p4 where`.
fn scope_local_dir(conn: &Conn, scope: &str) -> Option<String> {
if scope.is_empty() {
return None;
}
let spec = format!("{}/...", scope.trim_end_matches(['/', '.']));
run_json(conn, &["where", &spec])
.ok()?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
.map(|p| p.trim_end_matches(['.', '/', '\\']).to_string())
}
/// First file with the given extension in `dir` (checked, then one level of subdirs).
fn find_ext(dir: &str, ext: &str) -> String {
let matches = |p: &std::path::Path| p.extension().map_or(false, |x| x.eq_ignore_ascii_case(ext));
if let Ok(entries) = std::fs::read_dir(dir) {
let mut subdirs = Vec::new();
for e in entries.flatten() {
let p = e.path();
if matches(&p) {
return p.to_string_lossy().to_string();
}
if p.is_dir() {
subdirs.push(p);
}
}
for sd in subdirs {
if let Ok(inner) = std::fs::read_dir(&sd) {
for e in inner.flatten() {
let p = e.path();
if matches(&p) {
return p.to_string_lossy().to_string();
}
}
}
}
}
String::new()
}
/// Local path of a `.uproject` in the scope (or "" if not a UE project).
#[tauri::command]
async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "uproject")).unwrap_or_default())
}
/// Local path of a Visual Studio `.sln` in the scope (or "" if none).
#[tauri::command]
async fn find_sln(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "sln")).unwrap_or_default())
}
/// Build a Visual Studio solution with MSBuild (located via vswhere), streaming
/// its output line-by-line as `build-log` events. Uses the solution's default
/// configuration — for an Unreal project that compiles the game module.
#[tauri::command]
async fn build_sln(path: String, config: String) -> Result<String, String> {
if !std::path::Path::new(&path).exists() {
return Err(format!("Solution not found: {path}"));
}
let emit = |line: String, done: bool, ok: bool| {
if let Some(app) = APP.get() {
let _ = app.emit(
"build-log",
serde_json::json!({ "line": line, "done": done, "ok": ok }),
);
}
};
// locate MSBuild.exe via vswhere (falls back to PATH)
let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into());
let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe");
let msbuild = {
let mut vc = Command::new(&vswhere);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
vc.creation_flags(0x0800_0000);
}
vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]);
match vc.output() {
Ok(o) => String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").trim().to_string(),
Err(_) => String::new(),
}
};
let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild };
emit(format!("MSBuild: {msbuild}"), false, false);
emit(
format!("Building {path}{}", if config.is_empty() { String::new() } else { format!(" [{config} | Win64]") }),
false,
false,
);
let mut cmd = Command::new(&msbuild);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
// build args; a specific UE configuration ("Development Editor", "Shipping", …) → Win64
let mut args: Vec<String> = vec![
path.clone(),
"/m".into(),
"/nologo".into(),
"/v:minimal".into(),
"/clp:NoSummary".into(),
];
if !config.is_empty() {
args.push(format!("/p:Configuration={config}"));
args.push("/p:Platform=Win64".into());
}
cmd.args(&args).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let m = format!("Could not start MSBuild (Visual Studio C++ tools installed?): {e}");
emit(m.clone(), true, false);
return Err(m);
}
};
// For Unreal, the .sln drives UnrealBuildTool; MSBuild can return non-zero for a
// side project even when UBT reports the game module built fine. So trust UBT's
// "Result: Succeeded" (and the absence of real compile errors) over the exit code.
let mut ubt_result = false;
let mut ubt_success = false;
let mut compile_error = false;
let mut scan = |line: &str| {
let l = line.to_ascii_lowercase();
if l.contains("result: succeeded") { ubt_result = true; ubt_success = true; }
if l.contains("result: failed") { ubt_result = true; }
if l.contains(": error ") || l.contains(") error ") || l.contains("error msb") {
compile_error = true;
}
};
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
if !line.trim().is_empty() {
scan(&line);
emit(line, false, false);
}
}
}
let status = child.wait().map_err(|e| e.to_string())?;
if let Some(mut se) = child.stderr.take() {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut se, &mut buf);
for line in buf.lines() {
if !line.trim().is_empty() {
scan(line);
emit(line.to_string(), false, false);
}
}
}
// UBT verdict wins when present; otherwise fall back to MSBuild's exit code
let ok = if ubt_result {
ubt_success && !compile_error
} else {
status.success()
};
emit(if ok { "✓ Build succeeded.".into() } else { "✗ Build FAILED.".into() }, true, ok);
if ok {
Ok("ok".into())
} else {
Err("Build failed".into())
}
}
/// Files and metadata of a submitted changelist (History detail).
#[tauri::command]
async fn p4_describe(state: State<'_, AppState>, change: String) -> Result<Value, String> {
@ -640,6 +921,26 @@ async fn read_depot(state: State<'_, AppState>, depot: String) -> Result<tauri::
.map_err(|e| format!("Could not read file: {e}"))
}
/// Read raw file bytes at an exact revision via `p4 print` (works for
/// submitted / historical revisions that may not be synced locally).
/// `spec` is a depot path optionally suffixed with a revision, e.g.
/// `//depot/foo.cpp#7`. `-q` suppresses the leading file header line.
#[tauri::command]
async fn print_depot(state: State<'_, AppState>, spec: String) -> Result<tauri::ipc::Response, String> {
let conn = current(&state)?;
let out = p4_cmd(&conn, false)
.args(["print", "-q", &spec])
.output()
.map_err(|e| format!("Failed to start p4: {e}"))?;
if !out.status.success() && out.stdout.is_empty() {
let e = String::from_utf8_lossy(&out.stderr).trim().to_string();
log_p4(&["print", "-q", &spec], 0, false, Some(&e));
return Err(if e.is_empty() { "p4 print failed".into() } else { e });
}
log_p4(&["print", "-q", &spec], out.stdout.len() as i64, true, None);
Ok(tauri::ipc::Response::new(out.stdout))
}
/// Switch the active workspace (client) without re-authenticating.
#[tauri::command]
async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result<Value, String> {
@ -662,6 +963,113 @@ async fn p4_depots(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
run_json(&conn, &["depots"])
}
/// All Perforce users (with last-access time → activity indicator).
#[tauri::command]
async fn p4_users(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
run_json(&conn, &["users"])
}
/// All groups on the server, or the groups a given user belongs to.
#[tauri::command]
async fn p4_groups(state: State<'_, AppState>, user: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
if user.is_empty() {
run_json(&conn, &["groups"])
} else {
run_json(&conn, &["groups", "-u", &user])
}
}
/// Add or remove a user from a group (role assignment). Requires admin/super.
/// Edits the group spec's Users list and pipes it back via `group -i`.
#[tauri::command]
async fn p4_group_member(
state: State<'_, AppState>,
group: String,
user: String,
add: bool,
) -> Result<String, String> {
let conn = current(&state)?;
let spec = run_text(&conn, &["group", "-o", &group])?;
// collect existing Users: entries (indented under the "Users:" field)
let mut users: Vec<String> = Vec::new();
let mut in_users = false;
let mut head = String::new();
for line in spec.lines() {
if line.starts_with("Users:") {
in_users = true;
continue;
}
if in_users {
if line.starts_with('\t') || line.starts_with(' ') {
let u = line.trim().to_string();
if !u.is_empty() {
users.push(u);
}
continue;
} else if line.trim().is_empty() {
continue;
} else {
in_users = false; // next field — stop collecting
}
}
if !in_users {
head.push_str(line);
head.push('\n');
}
}
users.retain(|u| !u.eq_ignore_ascii_case(&user));
if add {
users.push(user.clone());
}
// rebuild spec: head (everything except Users:) + fresh Users section
let mut out = head.trim_end().to_string();
out.push_str("\n\nUsers:\n");
for u in &users {
out.push('\t');
out.push_str(u);
out.push('\n');
}
let mut child = p4_cmd(&conn, false)
.args(["group", "-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(out.as_bytes()).map_err(|e| e.to_string())?;
}
let res = child.wait_with_output().map_err(|e| e.to_string())?;
if !res.status.success() {
return Err(String::from_utf8_lossy(&res.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&res.stdout).trim().to_string())
}
/// Path to the (git-ignored) OpenRouter API key, stored in the app config dir.
fn ai_key_path() -> Option<std::path::PathBuf> {
let app = APP.get()?;
let dir = app.path().app_config_dir().ok()?;
Some(dir.join("openrouter.key"))
}
#[tauri::command]
async fn read_ai_key() -> Result<String, String> {
Ok(ai_key_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default())
}
#[tauri::command]
async fn save_ai_key(key: String) -> Result<(), String> {
let p = ai_key_path().ok_or("No config dir")?;
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(&p, key.trim()).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default()
@ -695,6 +1103,8 @@ pub fn run() {
p4_submit,
p4_commit,
p4_submit_change,
p4_reopen_default,
p4_set_desc,
p4_add,
p4_edit,
p4_delete,
@ -702,11 +1112,22 @@ pub fn run() {
p4_scan,
p4_undo_change,
open_in_explorer,
find_uproject,
find_sln,
build_sln,
launch_file,
open_in_vscode,
p4_describe,
read_file,
read_depot,
print_depot,
p4_switch_client,
p4_depots,
p4_users,
p4_groups,
p4_group_member,
read_ai_key,
save_ai_key,
p4_dirs,
])
.run(tauri::generate_context!())

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "exbyte-depot",
"version": "0.1.0",
"version": "0.2.2",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -32,7 +32,9 @@ body{
}
button{font-family:var(--font)}
.win{height:100vh;width:100%;display:flex;flex-direction:column;
/* height divided by the zoom factor: CSS `zoom` shrinks the box but `vh` still
measures the unzoomed viewport, so without this a gap grows below at zoom<1 */
.win{height:calc(100vh / var(--zoom,1));width:100%;display:flex;flex-direction:column;
background:linear-gradient(180deg,var(--win-a),var(--win-b));overflow:hidden}
/* titlebar + inline menu */
@ -122,6 +124,43 @@ body.resizing{cursor:col-resize!important;user-select:none}
.pushbar b{color:var(--add)}
.pushdot{width:8px;height:8px;border-radius:50%;background:var(--add);box-shadow:0 0 8px var(--add);flex:0 0 auto}
.pusharr{margin-left:auto;font-weight:700;font-size:14px}
/* pending changelists (committed, not yet submitted) */
.pending{margin:8px 8px 2px;display:flex;flex-direction:column;gap:6px;max-height:42%;overflow-y:auto;flex:0 0 auto}
.pending-h{display:flex;align-items:center;gap:8px;padding:1px 4px 3px}
.pending-t{display:flex;align-items:center;gap:8px;font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--add);font-weight:700}
.pushall{margin-left:auto;font-size:11.5px;font-weight:600;color:var(--add);background:rgba(62,207,142,.1);border:1px solid rgba(62,207,142,.32);border-radius:8px;padding:4px 11px;cursor:pointer}
.pushall:hover{filter:brightness(1.08)}
.clcard{border:1px solid rgba(62,207,142,.28);background:rgba(62,207,142,.06);border-radius:11px;overflow:hidden;flex:0 0 auto}
.clcard-h{display:flex;align-items:center;gap:9px;padding:9px 11px;cursor:pointer}
.clcard-h:hover{background:rgba(62,207,142,.06)}
.cl-chev{width:15px;height:15px;flex:0 0 auto;color:var(--faint);display:grid;place-items:center;transition:transform .18s;transform:rotate(-90deg)}
.cl-chev.open{transform:rotate(0deg)}
.cl-chev svg{width:15px;height:15px}
.cl-num{font-family:var(--mono);font-size:12px;color:var(--accent-2);flex:0 0 auto;font-variant-numeric:tabular-nums}
.cl-desc{flex:1;min-width:0;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cl-count{flex:0 0 auto;font-size:11px;color:var(--faint)}
.cl-actions{flex:0 0 auto;display:flex;align-items:center;gap:5px}
.cl-submit{width:27px;height:27px;border:none;border-radius:8px;cursor:pointer;color:#fff;font-size:14px;font-weight:700;
background:linear-gradient(135deg,var(--add),#1f9f6e);box-shadow:0 4px 12px -5px rgba(62,207,142,.7)}
.cl-submit:hover{filter:brightness(1.1)}
.cl-more{width:27px;height:27px;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;cursor:pointer;font-size:15px;line-height:1;display:grid;place-items:center}
.cl-more:hover{color:var(--txt);border-color:var(--accent)}
.cl-files{border-top:1px solid rgba(62,207,142,.2);padding:4px}
.cl-empty{padding:8px 10px;font-size:11.5px;color:var(--faint)}
.cl-file{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:8px;cursor:pointer}
.cl-file:hover{background:var(--hover)}
.cl-file.sel{background:rgba(124,110,246,.14);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
.cl-fname{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
.cl-fname .n{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cl-fname .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* text prompt (edit description) */
.modal-card.prompt{width:460px;max-width:calc(100vw - 40px);text-align:left;align-items:stretch}
.modal-card.prompt h3{text-align:left}
.prompt-label{font-size:11.5px;color:var(--faint);margin:2px 0 9px}
.prompt-input{width:100%;min-height:96px;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-size:13px;color:var(--txt);font-family:var(--font);outline:none;line-height:1.5}
.prompt-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.15)}
.chhead{display:flex;align-items:center;gap:10px;padding:9px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
.chk{width:16px;height:16px;border-radius:6px;border:1.5px solid var(--accent);
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));display:grid;place-items:center;flex:0 0 auto;box-shadow:0 0 8px rgba(124,110,246,.4)}
@ -140,10 +179,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
.files{flex:1;overflow-y:auto;min-height:0;position:relative}
.vspace{position:relative;width:100%}
.file{position:absolute;left:7px;right:7px;height:50px;display:flex;align-items:center;gap:11px;padding:0 12px;cursor:pointer;
border-radius:11px;transition:background .13s}
border-radius:11px;transition:background .13s;user-select:none}
.file:hover{background:var(--hover)}
.file.sel{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
.file.sel::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
.file.primary::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
background:linear-gradient(180deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 8px rgba(124,110,246,.6)}
.fchk{width:15px;height:15px;border-radius:6px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
.fchk.ck{background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));border-color:var(--accent)}
@ -195,7 +234,9 @@ body.resizing{cursor:col-resize!important;user-select:none}
/* right-click context menu */
.ctxmenu{position:fixed;z-index:300;min-width:210px;padding:5px;border-radius:12px;
background:var(--elevated);border:1px solid var(--border);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.03) inset}
.ctxitem{padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
.ctxitem{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
.ctx-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center}
.ctx-ic svg{width:16px;height:16px}
.ctxitem:hover{background:var(--hover)}
.ctxitem.danger{color:var(--del)}
.ctxitem.danger:hover{background:rgba(242,99,126,.12)}
@ -248,6 +289,41 @@ body.resizing{cursor:col-resize!important;user-select:none}
cursor:pointer;font-size:12px;font-variant-numeric:tabular-nums}
.zval:hover{border-color:var(--accent)}
/* custom dropdown (replaces native <select>) */
.xsel{position:relative}
.xsel-trig{display:flex;align-items:center;gap:9px;width:100%;background:var(--input-bg);border:1px solid var(--border);
border-radius:10px;padding:10px 12px;cursor:pointer;color:var(--txt);font-family:var(--font);font-size:13px;text-align:left}
.xsel-trig:hover,.xsel.open .xsel-trig{border-color:var(--accent)}
.xsel.open .xsel-trig{box-shadow:0 0 0 3px rgba(124,110,246,.15)}
.xsel-trig > svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
.xsel-val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xsel-ph{color:var(--faint)}
.xsel-chev{flex:0 0 auto;color:var(--faint);display:grid;place-items:center;transition:transform .18s}
.xsel-chev svg{width:15px;height:15px}
.xsel.open .xsel-chev{transform:rotate(180deg)}
.xsel-list{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:200;max-height:264px;overflow-y:auto;padding:5px;
background:var(--elevated);border:1px solid var(--border);border-radius:12px;box-shadow:0 20px 48px -14px rgba(0,0,0,.5);animation:updin .16s ease}
.xsel-empty{padding:12px;text-align:center;color:var(--faint);font-size:12.5px}
.xsel-item{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:9px;cursor:pointer}
.xsel-item:hover{background:var(--hover)}
.xsel-item.on{background:rgba(124,110,246,.12)}
.xsel-ic{width:15px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
.xsel-ic svg{width:13px;height:13px}
.xsel-body{display:flex;flex-direction:column;gap:1px;min-width:0}
.xsel-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xsel-sub{font-size:11px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Launch-Unreal button (white icon on black / black on white per theme) */
.ue-btn{display:flex;align-items:center;gap:9px;flex:0 0 auto;align-self:center;height:40px;margin:0 8px;padding:0 15px;
border:none;border-radius:11px;cursor:pointer;background:var(--txt);color:var(--win-a);transition:filter .15s,transform .06s}
.ue-btn:hover{filter:brightness(1.12)}
.ue-btn:active{transform:scale(.97)}
.ue-ic{width:24px;height:24px;flex:0 0 auto;display:grid;place-items:center}
.ue-ic svg{width:24px;height:24px}
.ue-lbl{display:flex;flex-direction:column;line-height:1.12;text-align:left}
.ue-a{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;opacity:.72}
.ue-b{font-size:13px;font-weight:700}
/* bottom status bar (always visible) + hover log peek */
.statusbar{flex:0 0 26px;display:flex;align-items:center;gap:10px;padding:0 12px;border-top:1px solid var(--border);
background:var(--bar);font-size:11.5px;color:var(--muted);position:relative;user-select:none;z-index:60}
@ -325,6 +401,42 @@ body.resizing{cursor:col-resize!important;user-select:none}
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
/* MSBuild output overlay */
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .3s ease}
.build-head{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:13px 18px;border-bottom:1px solid var(--border-soft)}
.build-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;font-weight:700;font-size:16px;background:var(--panel-3);color:var(--muted)}
.build-ic.run{color:var(--accent-2)}
.build-ic.ok{background:rgba(62,207,142,.15);color:var(--add)}
.build-ic.fail{background:rgba(242,99,126,.15);color:var(--del)}
.build-ic svg{width:18px;height:18px}
.build-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
.build-ttl b{font-size:14px}
.build-ttl span{font-size:11.5px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.build-x{margin-left:auto;width:28px;height:28px;flex:0 0 auto;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;cursor:pointer;display:grid;place-items:center}
.build-x + .build-x{margin-left:6px}
.build-x:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
.build-x:disabled{opacity:.4;cursor:not-allowed}
/* minimized build chip */
.buildchip{position:fixed;right:18px;bottom:18px;z-index:360;display:flex;align-items:center;gap:10px;
padding:9px 10px 9px 13px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;max-width:300px}
.buildchip.run{border-color:rgba(124,110,246,.45)}
.buildchip.ok{border-color:rgba(62,207,142,.5)}
.buildchip.fail{border-color:rgba(242,99,126,.5)}
.buildchip:hover{filter:brightness(1.06)}
.bc-ic{width:20px;height:20px;flex:0 0 auto;display:grid;place-items:center;font-weight:700;font-size:14px}
.buildchip.run .bc-ic{color:var(--accent-2)}.buildchip.ok .bc-ic{color:var(--add)}.buildchip.fail .bc-ic{color:var(--del)}
.bc-txt{font-size:12.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bc-x{width:22px;height:22px;flex:0 0 auto;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
.bc-x:hover{background:var(--hover);color:var(--txt)}
.build-body{flex:1;overflow:auto;min-height:0;padding:10px 14px;font-family:var(--mono);font-size:11.5px;line-height:1.6;background:var(--stage-bg)}
.build-ln{white-space:pre-wrap;word-break:break-word;color:var(--muted)}
.build-ln.err{color:var(--del)}
.build-ln.warn{color:var(--edit)}
.build-ln.ok{color:var(--add);font-weight:600}
.tzone.push .ic{color:var(--add)}
.tzone.push .val{color:var(--add)}
.commit .row{display:flex;gap:10px;align-items:center}
@ -351,6 +463,8 @@ body.resizing{cursor:col-resize!important;user-select:none}
.gbtn{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:9px;padding:7px 12px;cursor:pointer;transition:.15s}
.gbtn:hover{color:var(--txt);border-color:var(--accent)}.gbtn svg{width:14px;height:14px}
.gbtn.vsc svg{color:#3aa0ff}
.gbtn.vsc:hover{border-color:#3aa0ff;color:#3aa0ff}
.asset{flex:1;min-height:0;display:grid;grid-template-rows:1fr auto}
.stage{position:relative;min-height:0;overflow:hidden;display:grid;place-items:center;
@ -404,6 +518,36 @@ body.resizing{cursor:col-resize!important;user-select:none}
.dl.add{background:rgba(62,207,142,.10)}.dl.add .c{color:var(--diff-add-c)}.dl.add .g{background:var(--add)}
.dl.del{background:rgba(242,99,126,.10)}.dl.del .c{color:var(--diff-del-c)}.dl.del .g{background:var(--del)}
.dl.hunk{background:rgba(124,110,246,.08)}.dl.hunk .c{color:var(--accent-2)}
/* code viewer (GitHub-style content + line numbers) */
.right-body{flex:1;min-height:0;display:flex;flex-direction:column}
.codebar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid var(--border-soft);font-size:12px}
.cb-badge{flex:0 0 auto;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);
background:var(--panel-3);border-radius:7px;padding:2px 9px}
.cb-badge.add{color:var(--add);background:rgba(62,207,142,.14)}
.cb-badge.diff{color:var(--accent-2);background:rgba(124,110,246,.14)}
.cb-name{color:var(--txt);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cb-lines{margin-left:auto;flex:0 0 auto;color:var(--faint);font-variant-numeric:tabular-nums}
.code{flex:1;min-height:0;overflow:auto;display:flex;font-family:var(--mono);font-size:12.5px;line-height:1.7}
.code-gutter{position:sticky;left:0;z-index:1;flex:0 0 auto;display:flex;flex-direction:column;text-align:right;
padding:10px 12px 10px 16px;color:var(--faint);background:var(--stage-bg);border-right:1px solid var(--border-soft);
user-select:none;font-variant-numeric:tabular-nums}
.code.added .code-gutter{color:var(--add);background:rgba(62,207,142,.06)}
.code-body{flex:1;margin:0;padding:10px 16px;white-space:pre;overflow:visible}
.code-body code{white-space:pre;font-family:inherit;background:none;padding:0}
.code.added .code-body{background:rgba(62,207,142,.04)}
/* highlight.js tokens mapped to the app palette (theme-aware) */
.hljs{color:var(--txt);background:none}
.hljs-comment,.hljs-quote{color:var(--faint);font-style:italic}
.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-name,.hljs-tag{color:var(--accent-2)}
.hljs-string,.hljs-attr,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-meta-string{color:var(--add)}
.hljs-number,.hljs-literal,.hljs-type,.hljs-params,.hljs-template-variable,.hljs-variable{color:var(--edit)}
.hljs-title,.hljs-title.function_,.hljs-section,.hljs-class .hljs-title{color:var(--txt);font-weight:600}
.hljs-attribute,.hljs-selector-id,.hljs-selector-class,.hljs-selector-attr{color:var(--accent)}
.hljs-meta,.hljs-comment.hljs-doctag{color:var(--muted)}
.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
.hljs-deletion{color:var(--del)}
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
/* history */
@ -414,6 +558,23 @@ body.resizing{cursor:col-resize!important;user-select:none}
/* virtualized changelist-file row (History detail) */
.crow{position:absolute;left:0;right:0;height:52px;display:flex;gap:12px;align-items:center;padding:0 16px;border-bottom:1px solid var(--border-soft)}
.crow:hover{background:var(--hover)}
.crow.click{cursor:pointer}
.crow.click:hover .crow-go{opacity:1;transform:translateX(0)}
.crow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
.crow-go{margin-left:auto;flex:0 0 auto;color:var(--faint);font-size:18px;line-height:1;opacity:0;transform:translateX(-4px);transition:opacity .12s,transform .12s;display:flex;align-items:center}
.crow-go svg{width:16px;height:16px}
.gbtn.back{padding:6px 8px}.gbtn.back svg{width:16px;height:16px}
/* History = GitHub-style split: changed-files list beside the file content (no covering) */
.histsplit{display:flex;min-width:0;min-height:0;overflow:hidden}
.histsplit>.right{flex:1 1 auto;min-width:0}
/* explicit width via a state-driven CSS variable */
.histsplit>.histlist{flex:none;width:var(--hist-w,340px);min-width:0;border-right:1px solid var(--border)}
.histsplit.solo>.histlist{flex:1 1 auto;width:auto;border-right:none} /* nothing open → list fills the panel */
/* plain in-flow 8px column between list and preview — only the cursor hints it's draggable.
position MUST override the base .vhandle{position:absolute}, or it falls out of the flex flow */
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
.histsplit .histlist .crow-go{opacity:0}
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
@ -496,3 +657,68 @@ body.resizing{cursor:col-resize!important;user-select:none}
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
/* ---------------- avatars ---------------- */
.avatar{position:relative;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;letter-spacing:.02em;text-transform:uppercase;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14);user-select:none}
.adot{position:absolute;right:-1px;bottom:-1px;width:9px;height:9px;border-radius:50%;border:2px solid var(--elevated);background:var(--faint)}
.adot.active{background:#3ecb8f}.adot.recent{background:#f4c04e}.adot.idle{background:#6b6b7a}
/* ---------------- AI commit summary ---------------- */
.summrow{display:flex;gap:8px;align-items:stretch}
.aibtn{flex:0 0 auto;display:flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:9px;padding:0 12px;
font-size:12px;font-weight:600;font-family:var(--font);cursor:pointer;color:var(--accent-2);
background:linear-gradient(135deg,rgba(124,110,246,.16),rgba(124,110,246,.06));transition:.15s}
.aibtn:hover:not(:disabled){filter:brightness(1.12);border-color:rgba(124,110,246,.5)}
.aibtn:disabled{opacity:.5;cursor:default}
.aibtn svg{width:15px;height:15px}
.aibtn.busy{color:var(--faint)}
.aibtn.icon{padding:0;width:34px;justify-content:center}
/* AI settings block inside Settings modal */
.ai-set{margin-top:16px;padding-top:14px;border-top:1px solid var(--border-soft)}
.ai-set-h{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;color:var(--txt);margin-bottom:6px}
.ai-set-h svg{width:16px;height:16px;color:var(--accent-2)}
.keyfield{display:flex;align-items:center;gap:6px;flex:1;max-width:230px}
.kf-in{flex:1;min-width:0;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 9px;font-size:12px;color:var(--txt);outline:none;font-family:var(--font)}
.kf-in.mono{font-family:var(--mono);font-size:11.5px}
.kf-in:focus{border-color:var(--accent)}
.kf-eye{flex:0 0 auto;width:28px;height:28px;border:1px solid var(--border);background:var(--panel);border-radius:8px;cursor:pointer;font-size:13px;line-height:1}
.ai-hint{font-size:11px;color:var(--faint);line-height:1.5;margin-top:8px}
/* ---------------- People & Roles ---------------- */
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
.picker.wide .ph-sub{margin-right:auto}
.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)}
.ur-filter svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
.ur-filter input{flex:1;background:none;border:none;outline:none;color:var(--txt);font-size:12.5px;font-family:var(--font)}
.ur-scroll{flex:1;overflow-y:auto;padding:6px}
.ur-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;cursor:pointer;transition:background .12s}
.ur-row:hover{background:var(--hover)}
.ur-row.on{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
.ur-meta{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
.ur-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px}
.ur-you{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--accent-2);background:rgba(124,110,246,.16);border-radius:6px;padding:1px 6px}
.ur-sub{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
.ur-dinfo{min-width:0}
.ur-dname{font-size:16px;font-weight:700}
.ur-dsub{font-size:12px;color:var(--faint);margin-top:2px}
.ur-grouphd{display:flex;align-items:center;gap:8px;padding:14px 20px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
.ur-groups{display:flex;flex-wrap:wrap;gap:8px;padding:0 20px}
.ur-chip{display:flex;align-items:center;gap:7px;border:1px solid var(--border);background:var(--panel);color:var(--muted);
border-radius:9px;padding:7px 11px;font-size:12.5px;font-weight:500;font-family:var(--font);cursor:pointer;transition:.14s}
.ur-chip:hover:not(:disabled){border-color:var(--accent);color:var(--txt)}
.ur-chip:disabled{opacity:.6;cursor:default}
.ur-chip.on{border-color:rgba(124,110,246,.5);background:rgba(124,110,246,.13);color:var(--txt)}
.urc-box{width:15px;height:15px;border-radius:5px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
.ur-chip.on .urc-box{background:var(--accent);border-color:var(--accent);color:#fff}
.urc-box svg{width:11px;height:11px}
.ur-detail .ai-hint{padding:12px 20px 20px}

File diff suppressed because it is too large Load Diff

123
src/CodeView.tsx Normal file
View File

@ -0,0 +1,123 @@
import { useEffect, useMemo, useState } from "react";
import hljs from "highlight.js/lib/core";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
import python from "highlight.js/lib/languages/python";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import json from "highlight.js/lib/languages/json";
import xml from "highlight.js/lib/languages/xml";
import ini from "highlight.js/lib/languages/ini";
import glsl from "highlight.js/lib/languages/glsl";
import { p4, OpenedFile, splitPath } from "./p4";
import { t } from "./i18n";
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("csharp", csharp);
hljs.registerLanguage("python", python);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("json", json);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("ini", ini);
hljs.registerLanguage("glsl", glsl);
// file extension -> highlight.js language (empty = no highlighting, plain text)
function langOf(name: string): string {
const e = (name.split(".").pop() || "").toLowerCase();
if (/^(cpp|cc|cxx|c\+\+|hpp|hxx|h|hh|inl|c|ino)$/.test(e)) return "cpp";
if (e === "cs") return "csharp";
if (e === "py") return "python";
if (/^(js|jsx|mjs|cjs)$/.test(e)) return "javascript";
if (/^(ts|tsx)$/.test(e)) return "typescript";
if (/^(json|uproject|uplugin)$/.test(e)) return "json";
if (/^(xml|html|htm|svg|xaml)$/.test(e)) return "xml";
if (/^(ini|cfg|conf|config|toml|editorconfig)$/.test(e)) return "ini";
if (/^(usf|ush|hlsl|glsl|shader|frag|vert)$/.test(e)) return "glsl";
return "";
}
const LANG_LABEL: Record<string, string> = { cpp: "C++", csharp: "C#", python: "Python", javascript: "JavaScript", typescript: "TypeScript", json: "JSON", xml: "XML", ini: "INI", glsl: "GLSL" };
function esc(s: string): string {
return s.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
const MAX = 800_000; // don't try to render/highlight enormous files
// Preview source files like GitHub: syntax-highlighted content with line numbers
// for new files, or a colored unified diff for edits.
export default function CodeView({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const spec = file._spec || ""; // set → read this exact submitted revision
const { name } = splitPath(dp);
const action = (file.action || "").toLowerCase();
const [diff, setDiff] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [err, setErr] = useState("");
const [tooBig, setTooBig] = useState(false);
useEffect(() => {
let live = true;
setDiff(null); setCode(null); setErr(""); setTooBig(false);
// working-copy edits show a diff against the depot; historical revisions just
// show their full content (no meaningful working diff to fetch).
if (!spec) p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
const load = spec ? p4.printDepot(spec) : p4.readDepot(dp);
load.then((buf) => {
if (!live) return;
if (buf.byteLength > MAX) { setTooBig(true); setCode(""); return; }
const bytes = new Uint8Array(buf);
// crude binary sniff: a NUL in the first chunk
if (bytes.subarray(0, 8000).includes(0)) { setCode(null); setErr("binary"); return; }
setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
}).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp, spec]);
const lang = langOf(name);
const highlighted = useMemo(() => {
if (code == null) return "";
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
catch { return esc(code); }
}, [code, lang]);
if (err === "binary") return <div className="nofile">{t("Binary file — no code preview.")}</div>;
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (tooBig) return <div className="nofile">{t("File is too large to preview.")}</div>;
// an edited file with a real diff → show the diff
if (diff && diff.trim() && action.includes("edit")) {
const lines = diff.split("\n");
return (
<div className="right-body">
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span></div>
<div className="diff">
{lines.map((ln, i) => {
let cls = "";
if (ln.startsWith("@@")) cls = "hunk";
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
return <div key={i} className={"dl " + cls}><span className="g" /><span className="c">{ln || " "}</span></div>;
})}
</div>
</div>
);
}
// otherwise (new file, or edit with no text diff) → full content with line numbers
if (code == null) return <div className="nofile">{t("Loading…")}</div>;
const total = code.length ? code.replace(/\n$/, "").split("\n").length : 0;
return (
<div className="right-body">
<div className="codebar">
<span className={"cb-badge" + (action.includes("add") ? " add" : "")}>{action.includes("add") ? t("New file") : lang ? (LANG_LABEL[lang] || "") : t("Text")}</span>
<span className="cb-name">{name}</span>
<span className="cb-lines">{t("{n} lines", { n: total })}</span>
</div>
<div className={"code" + (action.includes("add") ? " added" : "")}>
<div className="code-gutter">{Array.from({ length: total }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
<pre className="code-body"><code className="hljs" dangerouslySetInnerHTML={{ __html: highlighted }} /></pre>
</div>
</div>
);
}

View File

@ -14,7 +14,7 @@ import { t } from "./i18n";
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
export default function ModelViewer({ depot, name }: { depot: string; name: string }) {
export default function ModelViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
const mount = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
@ -138,7 +138,7 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
(async () => {
try {
const buf = await p4.readDepot(depot);
const buf = spec ? await p4.printDepot(spec) : await p4.readDepot(depot);
if (disposed) return;
const manager = new THREE.LoadingManager();
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
@ -173,7 +173,7 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
api.current = null;
};
// eslint-disable-next-line
}, [depot, name]);
}, [depot, name, spec]);
const fmt = (n: number) => n.toLocaleString("ru");
return (

74
src/ai.ts Normal file
View File

@ -0,0 +1,74 @@
// OpenRouter-powered commit summary + small avatar helpers.
import { p4, OpenedFile, statusOf } from "./p4";
const MODEL_KEY = "exd-ai-model";
const KEY_KEY = "exd-ai-key";
// cheap + reliable default; change to a "…:free" model in Settings to pay nothing
export const DEFAULT_MODEL = "openai/gpt-4o-mini";
export function getModel(): string { try { return localStorage.getItem(MODEL_KEY) || DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } }
export function setModel(m: string) { try { localStorage.setItem(MODEL_KEY, m.trim() || DEFAULT_MODEL); } catch {} }
export function getKeyLocal(): string { try { return localStorage.getItem(KEY_KEY) || ""; } catch { return ""; } }
export function setKeyLocal(k: string) { try { localStorage.setItem(KEY_KEY, k.trim()); } catch {} }
// key resolution: localStorage first, else the app-config file (git-ignored)
export async function resolveKey(): Promise<string> {
const local = getKeyLocal();
if (local) return local;
try { return (await p4.readAiKey()) || ""; } catch { return ""; }
}
// generate a commit summary + description from the changed files
export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string; description: string }> {
const key = await resolveKey();
if (!key) throw new Error("No OpenRouter API key — set it in Settings");
const list = files.slice(0, 200).map((f) => `${statusOf(f.action).ch} ${f.depotFile || ""}`).join("\n");
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" },
body: JSON.stringify({
model: getModel(),
max_tokens: 300,
temperature: 0.3,
messages: [
{ role: "system", content: "You write concise commit messages for a Perforce/Unreal project. Given a list of changed files (+ added, ± edited, deleted), reply with ONE short imperative summary line (under 72 chars, no trailing period), then a blank line, then an optional 1-3 line description. Plain text only — no markdown, no code fences, no quotes." },
{ role: "user", content: `Changed files:\n${list}` },
],
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`);
}
const data = await res.json();
const content: string = (data?.choices?.[0]?.message?.content || "").trim();
if (!content) throw new Error("Empty AI response");
const lines = content.split("\n");
const summary = (lines[0] || "").replace(/^["'`*]+|["'`*]+$/g, "").trim();
const description = lines.slice(1).join("\n").trim();
return { summary, description };
}
// --- avatar helpers ---
export function initials(name: string, user: string): string {
const n = (name || "").trim();
if (n) {
const p = n.split(/\s+/);
return (p[0][0] + (p[1]?.[0] || "")).toUpperCase();
}
return (user || "?").slice(0, 2).toUpperCase();
}
export function avatarColor(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return `hsl(${h % 360} 52% 52%)`;
}
// activity level from a unix-epoch last-access string
export function activity(access?: string): "active" | "recent" | "idle" {
const n = Number(access || 0) * 1000;
if (!n) return "idle";
const ago = Date.now() - n;
if (ago < 60 * 60 * 1000) return "active"; // < 1h
if (ago < 24 * 60 * 60 * 1000) return "recent"; // < 1d
return "idle";
}

View File

@ -25,6 +25,7 @@ const D: Record<string, Tr> = {
"Minimize": { ru: "Свернуть", de: "Minimieren", fr: "Réduire", es: "Minimizar" },
"Maximize": { ru: "Развернуть", de: "Maximieren", fr: "Agrandir", es: "Maximizar" },
"Close": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
"Expand": { ru: "Развернуть", de: "Erweitern", fr: "Agrandir", es: "Expandir" },
"Theme": { ru: "Тема", de: "Thema", fr: "Thème", es: "Tema" },
"Restoring session…": { ru: "Восстановление сессии…", de: "Sitzung wird wiederhergestellt…", fr: "Restauration de la session…", es: "Restaurando sesión…" },
// startup splash
@ -67,12 +68,72 @@ const D: Record<string, Tr> = {
"Get Latest": { ru: "Get Latest", de: "Get Latest", fr: "Get Latest", es: "Get Latest" },
"Working…": { ru: "Работаю…", de: "Arbeite…", fr: "En cours…", es: "Trabajando…" },
"pull from server": { ru: "забрать с сервера (pull)", de: "vom Server holen (pull)", fr: "récupérer du serveur (pull)", es: "traer del servidor (pull)" },
"Launch": { ru: "Запустить", de: "Starten", fr: "Lancer", es: "Iniciar" },
"Launch Unreal Engine": { ru: "Запустить Unreal Engine", de: "Unreal Engine starten", fr: "Lancer Unreal Engine", es: "Iniciar Unreal Engine" },
"Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" },
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
"AI commit messages": { ru: "ИИ-сообщения коммитов", de: "KI-Commit-Nachrichten", fr: "Messages de commit IA", es: "Mensajes de commit con IA" },
"Draft a commit message with AI from the selected files": { ru: "Сгенерировать сообщение коммита из выбранных файлов через ИИ", de: "Commit-Nachricht per KI aus den gewählten Dateien entwerfen", fr: "Rédiger un message de commit par IA à partir des fichiers sélectionnés", es: "Redactar un mensaje de commit con IA a partir de los archivos seleccionados" },
"Select some files first.": { ru: "Сначала выбери файлы.", de: "Wähle zuerst Dateien aus.", fr: "Sélectionnez dabord des fichiers.", es: "Selecciona primero algunos archivos." },
"AI drafted a commit message — review & edit before committing.": { ru: "ИИ подготовил сообщение — проверь и поправь перед коммитом.", de: "KI hat eine Nachricht entworfen — vor dem Commit prüfen und anpassen.", fr: "LIA a rédigé un message — vérifiez et modifiez avant de valider.", es: "La IA redactó un mensaje — revísalo y edítalo antes de confirmar." },
"Set an OpenRouter API key in Settings → AI first.": { ru: "Сначала укажи ключ OpenRouter в Настройках → ИИ.", de: "Zuerst einen OpenRouter-API-Schlüssel unter Einstellungen → KI eintragen.", fr: "Renseignez dabord une clé API OpenRouter dans Paramètres → IA.", es: "Primero configura una clave API de OpenRouter en Ajustes → IA." },
"OpenRouter key": { ru: "Ключ OpenRouter", de: "OpenRouter-Schlüssel", fr: "Clé OpenRouter", es: "Clave OpenRouter" },
"Model": { ru: "Модель", de: "Modell", fr: "Modèle", es: "Modelo" },
"Hide": { ru: "Скрыть", de: "Verbergen", fr: "Masquer", es: "Ocultar" },
"Show": { ru: "Показать", de: "Anzeigen", fr: "Afficher", es: "Mostrar" },
"Stored only on this machine. Get a key at openrouter.ai — a “:free” model costs nothing.": { ru: "Хранится только на этом компьютере. Получи ключ на openrouter.ai — модель с «:free» бесплатна.", de: "Nur auf diesem Gerät gespeichert. Schlüssel auf openrouter.ai holen — ein „:free“-Modell kostet nichts.", fr: "Stocké uniquement sur cette machine. Obtenez une clé sur openrouter.ai — un modèle « :free » est gratuit.", es: "Se guarda solo en este equipo. Consigue una clave en openrouter.ai — un modelo « :free » no cuesta nada." },
"People & Roles…": { ru: "Пользователи и роли…", de: "Personen & Rollen…", fr: "Personnes et rôles…", es: "Personas y roles…" },
"People & Roles": { ru: "Пользователи и роли", de: "Personen & Rollen", fr: "Personnes et rôles", es: "Personas y roles" },
"People & Roles — who works on this depot": { ru: "Пользователи и роли — кто работает над депотом", de: "Personen & Rollen — wer an diesem Depot arbeitet", fr: "Personnes et rôles — qui travaille sur ce dépôt", es: "Personas y roles — quién trabaja en este depósito" },
"{n} on this depot": { ru: "{n} в депоте", de: "{n} in diesem Depot", fr: "{n} sur ce dépôt", es: "{n} en este depósito" },
"Loading users…": { ru: "Загрузка пользователей…", de: "Benutzer werden geladen…", fr: "Chargement des utilisateurs…", es: "Cargando usuarios…" },
"Filter people…": { ru: "Фильтр по людям…", de: "Personen filtern…", fr: "Filtrer les personnes…", es: "Filtrar personas…" },
"you": { ru: "вы", de: "Sie", fr: "vous", es: "tú" },
"No matches.": { ru: "Нет совпадений.", de: "Keine Treffer.", fr: "Aucun résultat.", es: "Sin coincidencias." },
"Pick a person to see and edit their roles.": { ru: "Выбери человека, чтобы увидеть и изменить его роли.", de: "Wähle eine Person, um ihre Rollen zu sehen und zu bearbeiten.", fr: "Choisissez une personne pour voir et modifier ses rôles.", es: "Elige a una persona para ver y editar sus roles." },
"active {t}": { ru: "активность {t}", de: "aktiv {t}", fr: "actif {t}", es: "activo {t}" },
"Roles / groups": { ru: "Роли / группы", de: "Rollen / Gruppen", fr: "Rôles / groupes", es: "Roles / grupos" },
"No groups defined on the server.": { ru: "На сервере нет групп.", de: "Auf dem Server sind keine Gruppen definiert.", fr: "Aucun groupe défini sur le serveur.", es: "No hay grupos definidos en el servidor." },
"Toggling a group grants or revokes that role. Requires admin rights on the server.": { ru: "Переключение группы выдаёт или отзывает роль. Нужны права администратора на сервере.", de: "Das Umschalten einer Gruppe erteilt oder entzieht die Rolle. Erfordert Admin-Rechte auf dem Server.", fr: "Activer/désactiver un groupe accorde ou retire ce rôle. Nécessite des droits admin sur le serveur.", es: "Activar o desactivar un grupo concede o revoca ese rol. Requiere permisos de administrador en el servidor." },
"You need admin rights on the server to change roles.": { ru: "Чтобы менять роли, нужны права администратора на сервере.", de: "Zum Ändern von Rollen sind Admin-Rechte auf dem Server nötig.", fr: "Vous avez besoin de droits admin sur le serveur pour changer les rôles.", es: "Necesitas permisos de administrador en el servidor para cambiar roles." },
"Added {u} to “{g}”.": { ru: "{u} добавлен в «{g}».", de: "{u} zu „{g}“ hinzugefügt.", fr: "{u} ajouté à « {g} ».", es: "{u} añadido a « {g} »." },
"Removed {u} from “{g}”.": { ru: "{u} удалён из «{g}».", de: "{u} aus „{g}“ entfernt.", fr: "{u} retiré de « {g} ».", es: "{u} eliminado de « {g} »." },
"Building…": { ru: "Сборка…", de: "Wird gebaut…", fr: "Compilation…", es: "Compilando…" },
"Build succeeded": { ru: "Сборка успешна", de: "Build erfolgreich", fr: "Compilation réussie", es: "Compilación correcta" },
"Build failed": { ru: "Сборка провалена", de: "Build fehlgeschlagen", fr: "Échec de la compilation", es: "Compilación fallida" },
"Starting MSBuild…": { ru: "Запуск MSBuild…", de: "MSBuild wird gestartet…", fr: "Démarrage de MSBuild…", es: "Iniciando MSBuild…" },
"Build configuration": { ru: "Конфигурация сборки", de: "Build-Konfiguration", fr: "Configuration de build", es: "Configuración de compilación" },
"Editor build for working in-engine (default)": { ru: "Сборка редактора для работы в движке (по умолчанию)", de: "Editor-Build zum Arbeiten in der Engine (Standard)", fr: "Build éditeur pour travailler dans le moteur (défaut)", es: "Build del editor para trabajar en el motor (predeterminado)" },
"Standalone game, dev config": { ru: "Отдельная игра, dev-конфиг", de: "Eigenständiges Spiel, Dev-Konfig", fr: "Jeu autonome, config dev", es: "Juego independiente, config dev" },
"Game with debuggable game code": { ru: "Игра с отлаживаемым кодом", de: "Spiel mit debugfähigem Code", fr: "Jeu avec code débogable", es: "Juego con código depurable" },
"Optimized release build": { ru: "Оптимизированный релизный билд", de: "Optimierter Release-Build", fr: "Build de release optimisé", es: "Build de lanzamiento optimizado" },
"Builds via MSBuild → UnrealBuildTool": { ru: "Сборка через MSBuild → UnrealBuildTool", de: "Build über MSBuild → UnrealBuildTool", fr: "Compile via MSBuild → UnrealBuildTool", es: "Compila con MSBuild → UnrealBuildTool" },
"Open this project in Unreal Engine": { ru: "Открыть проект в Unreal Engine", de: "Projekt in Unreal Engine öffnen", fr: "Ouvrir le projet dans Unreal Engine", es: "Abrir el proyecto en Unreal Engine" },
"Launching Unreal…": { ru: "Запуск Unreal…", de: "Unreal wird gestartet…", fr: "Lancement dUnreal…", es: "Iniciando Unreal…" },
// tabs / list
"Changes": { ru: "Changes", de: "Änderungen", fr: "Modifications", es: "Cambios" },
"History": { ru: "History", de: "Verlauf", fr: "Historique", es: "Historial" },
"Filter {n} files…": { ru: "Фильтр {n} файлов…", de: "{n} Dateien filtern…", fr: "Filtrer {n} fichiers…", es: "Filtrar {n} archivos…" },
"{n} changelist(s) committed — ready to Submit": { ru: "{n} changelist(ов) закоммичено — готово к Submit", de: "{n} Changelist(s) committed — bereit zum Submit", fr: "{n} changelist(s) validés — prêts à envoyer", es: "{n} changelist(s) confirmados — listos para enviar" },
"Pending · ready to Submit": { ru: "Pending · готово к Submit", de: "Ausstehend · bereit zum Submit", fr: "En attente · prêt à envoyer", es: "Pendiente · listo para enviar" },
"Submit all": { ru: "Отправить всё", de: "Alle senden", fr: "Tout envoyer", es: "Enviar todo" },
"Submit all to the server": { ru: "Отправить все на сервер", de: "Alle an Server senden", fr: "Tout envoyer au serveur", es: "Enviar todo al servidor" },
"Submit": { ru: "Отправить", de: "Senden", fr: "Envoyer", es: "Enviar" },
"More": { ru: "Ещё", de: "Mehr", fr: "Plus", es: "Más" },
"Submit changelist #{n}?": { ru: "Отправить changelist #{n}?", de: "Changelist #{n} senden?", fr: "Envoyer le changelist #{n} ?", es: "¿Enviar changelist #{n}?" },
"This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone.": { ru: "Этот changelist уйдёт в депо Perforce и станет доступен команде. Это push — отменить нельзя.", de: "Diese Changelist geht an den Perforce-Server und wird für das Team verfügbar. Das ist ein Push — nicht widerrufbar.", fr: "Ce changelist ira sur le serveur Perforce et sera disponible pour léquipe. Cest un push — irréversible.", es: "Este changelist irá al servidor Perforce y estará disponible para el equipo. Es un push — no se puede deshacer." },
"Submitted changelist #{n}.": { ru: "Changelist #{n} отправлен.", de: "Changelist #{n} übermittelt.", fr: "Changelist #{n} envoyé.", es: "Changelist #{n} enviado." },
"Uncommit (back to working)": { ru: "Разкоммитить (обратно в Working)", de: "Uncommit (zurück zu Working)", fr: "Décommiter (retour au travail)", es: "Descomitear (volver a trabajo)" },
"Moved back to working changes.": { ru: "Возвращено в текущие изменения.", de: "Zurück zu den Arbeitsänderungen verschoben.", fr: "Renvoyé aux modifications en cours.", es: "Devuelto a los cambios de trabajo." },
"Edit description": { ru: "Изменить описание", de: "Beschreibung bearbeiten", fr: "Modifier la description", es: "Editar descripción" },
"Changelist #{n}": { ru: "Changelist #{n}", de: "Changelist #{n}", fr: "Changelist #{n}", es: "Changelist #{n}" },
"Description updated.": { ru: "Описание обновлено.", de: "Beschreibung aktualisiert.", fr: "Description mise à jour.", es: "Descripción actualizada." },
"(files are outside the current working folder)": { ru: "(файлы вне текущей рабочей папки)", de: "(Dateien außerhalb des Arbeitsordners)", fr: "(fichiers hors du dossier de travail)", es: "(archivos fuera de la carpeta de trabajo)" },
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace cest lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
@ -122,6 +183,14 @@ const D: Record<string, Tr> = {
"Revert file?": { ru: "Откатить файл?", de: "Datei zurücksetzen?", fr: "Annuler le fichier ?", es: "¿Revertir archivo?" },
"{name}\n\nLocal edits to this file will be lost.": { ru: "{name}\n\nЛокальные правки этого файла будут потеряны.", de: "{name}\n\nLokale Änderungen an dieser Datei gehen verloren.", fr: "{name}\n\nLes modifications locales de ce fichier seront perdues.", es: "{name}\n\nLos cambios locales de este archivo se perderán." },
"Revert": { ru: "Откатить", de: "Zurücksetzen", fr: "Annuler", es: "Revertir" },
"Discard changes": { ru: "Отменить изменения", de: "Änderungen verwerfen", fr: "Abandonner les modifications", es: "Descartar cambios" },
"Discard changes · {n} files": { ru: "Отменить изменения · {n} файлов", de: "Änderungen verwerfen · {n} Dateien", fr: "Abandonner · {n} fichiers", es: "Descartar · {n} archivos" },
"Discard changes?": { ru: "Отменить изменения?", de: "Änderungen verwerfen?", fr: "Abandonner les modifications ?", es: "¿Descartar cambios?" },
"Discard": { ru: "Отменить", de: "Verwerfen", fr: "Abandonner", es: "Descartar" },
"Local changes to {name} will be lost permanently — the file returns to the server version.": { ru: "Локальные изменения в {name} будут потеряны безвозвратно — файл вернётся к версии с сервера.", de: "Lokale Änderungen an {name} gehen endgültig verloren — die Datei kehrt zur Serverversion zurück.", fr: "Les modifications locales de {name} seront perdues définitivement — le fichier revient à la version du serveur.", es: "Los cambios locales en {name} se perderán para siempre — el archivo vuelve a la versión del servidor." },
"Discarded: {name}": { ru: "Отменено: {name}", de: "Verworfen: {name}", fr: "Abandonné : {name}", es: "Descartado: {name}" },
"Edit in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
"Opening in VS Code…": { ru: "Открываю в VS Code…", de: "Wird in VS Code geöffnet…", fr: "Ouverture dans VS Code…", es: "Abriendo en VS Code…" },
"Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" },
"Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et lenverra au serveur. Lhistorique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" },
"Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" },
@ -185,6 +254,8 @@ const D: Record<string, Tr> = {
"(no description)": { ru: "(без описания)", de: "(keine Beschreibung)", fr: "(sans description)", es: "(sin descripción)" },
"{user} · {time} · {n} file(s)": { ru: "{user} · {time} · {n} файл(ов)", de: "{user} · {time} · {n} Datei(en)", fr: "{user} · {time} · {n} fichier(s)", es: "{user} · {time} · {n} archivo(s)" },
"Loading file list…": { ru: "Загрузка списка файлов…", de: "Dateiliste wird geladen…", fr: "Chargement de la liste des fichiers…", es: "Cargando lista de archivos…" },
"Back to file list": { ru: "Назад к списку файлов", de: "Zurück zur Dateiliste", fr: "Retour à la liste des fichiers", es: "Volver a la lista de archivos" },
"in #{n}": { ru: "в #{n}", de: "in #{n}", fr: "dans #{n}", es: "en #{n}" },
// history list
"Loading history…": { ru: "Загрузка истории…", de: "Verlauf wird geladen…", fr: "Chargement de lhistorique…", es: "Cargando historial…" },
"No submitted changes.": { ru: "Нет засабмиченных изменений.", de: "Keine übermittelten Änderungen.", fr: "Aucune modification envoyée.", es: "No hay cambios enviados." },
@ -198,6 +269,12 @@ const D: Record<string, Tr> = {
"Select a file on the left": { ru: "Выберите файл слева", de: "Wähle links eine Datei", fr: "Sélectionnez un fichier à gauche", es: "Selecciona un archivo a la izquierda" },
"Loading diff…": { ru: "Загрузка diff…", de: "Diff wird geladen…", fr: "Chargement du diff…", es: "Cargando diff…" },
"No text differences (or binary file).": { ru: "Нет текстовых отличий (или бинарный файл).", de: "Keine Textunterschiede (oder Binärdatei).", fr: "Aucune différence de texte (ou fichier binaire).", es: "Sin diferencias de texto (o archivo binario)." },
"Binary file — no code preview.": { ru: "Бинарный файл — превью кода нет.", de: "Binärdatei — keine Code-Vorschau.", fr: "Fichier binaire — pas daperçu du code.", es: "Archivo binario — sin vista previa de código." },
"File is too large to preview.": { ru: "Файл слишком большой для превью.", de: "Datei zu groß für die Vorschau.", fr: "Fichier trop volumineux pour laperçu.", es: "Archivo demasiado grande para previsualizar." },
"Diff": { ru: "Diff", de: "Diff", fr: "Diff", es: "Diff" },
"New file": { ru: "Новый файл", de: "Neue Datei", fr: "Nouveau fichier", es: "Archivo nuevo" },
"Text": { ru: "Текст", de: "Text", fr: "Texte", es: "Texto" },
"{n} lines": { ru: "{n} строк", de: "{n} Zeilen", fr: "{n} lignes", es: "{n} líneas" },
"Image": { ru: "Изображение", de: "Bild", fr: "Image", es: "Imagen" },
"Resolution": { ru: "Разрешение", de: "Auflösung", fr: "Résolution", es: "Resolución" },
"Format": { ru: "Формат", de: "Format", fr: "Format", es: "Formato" },

View File

@ -22,9 +22,16 @@ export interface OpenedFile {
change?: string; // "default" or a number
user?: string;
client?: string;
_spec?: string; // when set, read this exact rev via `p4 print` (submitted/historical file)
[k: string]: unknown;
}
// choose the byte source: an exact submitted revision (`p4 print`) when `_spec`
// is set, otherwise the working copy (local file via `p4 where`).
export function fileBytes(f: OpenedFile): Promise<ArrayBuffer> {
return f._spec ? p4.printDepot(f._spec) : p4.readDepot(f.depotFile || "");
}
export interface Client {
client?: string;
Root?: string;
@ -64,6 +71,8 @@ export const p4 = {
commit: (description: string, files: string[]) =>
invoke<string>("p4_commit", { description, files }),
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
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 }),
edit: (files: string[]) => invoke<OpenedFile[]>("p4_edit", { files }),
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
@ -72,6 +81,11 @@ export const p4 = {
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
launchFile: (path: string) => invoke<void>("launch_file", { path }),
openInVscode: (depot: string) => invoke<void>("open_in_vscode", { depot }),
readDepot: async (depot: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("read_depot", { depot });
if (r instanceof ArrayBuffer) return r;
@ -79,9 +93,24 @@ export const p4 = {
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
// read raw bytes at an exact revision (submitted/historical), e.g. "//depot/f.cpp#7"
printDepot: async (spec: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("print_depot", { spec });
if (r instanceof ArrayBuffer) return r;
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
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 }),
readAiKey: () => invoke<string>("read_ai_key"),
saveAiKey: (key: string) => invoke<void>("save_ai_key", { key }),
};
export interface User { User?: string; Email?: string; FullName?: string; Access?: string; Update?: string; [k: string]: unknown }
export interface Dir { dir?: string; [k: string]: unknown }
// depot path -> short leaf name (last segment)
export function leaf(path?: string): string {