Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e2cac4437 | |||
| 168b6afab3 | |||
| de6d55ad84 | |||
| 1675a7cd49 | |||
| 0d04c8b2a4 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
@ -951,7 +951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
21
src-tauri/app-icon.svg
Normal 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 |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.7 KiB |
@ -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>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 48 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 820 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@ -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(
|
||||
@ -620,13 +683,17 @@ async fn open_in_vscode(state: State<'_, AppState>, depot: String) -> Result<(),
|
||||
.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);
|
||||
}
|
||||
c.arg("/C").arg(format!("code \"{local}\""));
|
||||
// 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(())
|
||||
@ -711,7 +778,7 @@ async fn find_sln(state: State<'_, AppState>, scope: String) -> Result<String, S
|
||||
/// 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) -> Result<String, String> {
|
||||
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}"));
|
||||
}
|
||||
@ -742,7 +809,11 @@ async fn build_sln(path: String) -> Result<String, String> {
|
||||
};
|
||||
let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild };
|
||||
emit(format!("MSBuild: {msbuild}"), false, false);
|
||||
emit(format!("Building {path} …"), 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)]
|
||||
@ -750,9 +821,19 @@ async fn build_sln(path: String) -> Result<String, String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
cmd.args([&path, "/m", "/nologo", "/v:minimal", "/clp:NoSummary"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
// 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) => {
|
||||
@ -840,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> {
|
||||
@ -862,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()
|
||||
@ -895,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,
|
||||
@ -910,8 +1120,14 @@ pub fn run() {
|
||||
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!())
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "exbyte-depot",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
123
src/App.css
@ -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)}
|
||||
@ -519,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}
|
||||
@ -601,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}
|
||||
|
||||
448
src/App.tsx
@ -7,8 +7,9 @@ import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import UpdateBanner from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
|
||||
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { aiSummary, initials, avatarColor, activity, getModel, setModel, getKeyLocal, setKeyLocal } from "./ai";
|
||||
|
||||
/* ---------------- window controls (frameless) ---------------- */
|
||||
function WinControls() {
|
||||
@ -37,6 +38,10 @@ const I = {
|
||||
spinner: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.3)" strokeWidth="2"/><path d="M12 3a9 9 0 0 1 9 9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/></svg>,
|
||||
cube: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.5"/></svg>,
|
||||
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
back: <svg viewBox="0 0 24 24" fill="none"><path d="M19 12H5M11 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
chevron: <svg viewBox="0 0 24 24" fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
spark: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M18.5 15.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z" fill="currentColor"/></svg>,
|
||||
people: <svg viewBox="0 0 24 24" fill="none"><circle cx="9" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 6.1M17.5 14.2A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
@ -69,6 +74,10 @@ function useZoom(): [number, (z: number) => void] {
|
||||
});
|
||||
useEffect(() => {
|
||||
(document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom);
|
||||
// CSS `zoom` scales layout but leaves `100vh` measuring the *unzoomed* viewport,
|
||||
// so at zoom<1 a gap grows at the bottom. Expose the factor so the root can
|
||||
// compensate its height (see `.win` in App.css).
|
||||
document.documentElement.style.setProperty("--zoom", String(zoom));
|
||||
try { localStorage.setItem("exd-zoom", String(zoom)); } catch {}
|
||||
}, [zoom]);
|
||||
return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))];
|
||||
@ -262,6 +271,7 @@ type ModalState =
|
||||
| { kind: "workspaces"; items: Client[]; current: string }
|
||||
| { kind: "scope"; path: string; dirs: Dir[] }
|
||||
| { kind: "settings" }
|
||||
| { kind: "users" }
|
||||
| { kind: "about" };
|
||||
|
||||
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
||||
@ -276,9 +286,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [sel, setSel] = useState<number | null>(null); // primary row (preview + shift anchor)
|
||||
const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
|
||||
const [previewCL, setPreviewCL] = useState<OpenedFile | null>(null); // preview override (pending-CL file)
|
||||
const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
|
||||
const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => void }>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [desc, setDesc] = useState(""); // commit summary (first line)
|
||||
const [descBody, setDescBody] = useState(""); // extended description (optional)
|
||||
const [desc, setDesc] = useState(() => { try { return localStorage.getItem("exd-draft-summary") || ""; } catch { return ""; } }); // commit summary (first line) — persisted draft
|
||||
const [descBody, setDescBody] = useState(() => { try { return localStorage.getItem("exd-draft-body") || ""; } catch { return ""; } }); // extended description (optional) — persisted draft
|
||||
// keep the typed commit message as a draft so a refresh/restart never loses it; it is cleared only on Submit
|
||||
useEffect(() => { try { localStorage.setItem("exd-draft-summary", desc); } catch {} }, [desc]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false); // background reconcile in progress
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@ -287,6 +303,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [detail, setDetail] = useState<Describe | null>(null);
|
||||
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
|
||||
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
|
||||
const [histFile, setHistFile] = useState<OpenedFile | null>(null); // drilled-into submitted file (read-only preview)
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
|
||||
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||
@ -299,6 +316,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [buildOk, setBuildOk] = useState<boolean | null>(null);
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
|
||||
const [buildPick, setBuildPick] = useState(false); // configuration picker before building
|
||||
const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@ -310,14 +329,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
// resizable panels (persisted): left panel width + Get-Latest zone width
|
||||
const [leftW, setLeftW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; });
|
||||
const [syncW, setSyncW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; });
|
||||
const [histW, setHistW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-hist-w")); return v >= 240 ? v : 340; }); // History file-list column width
|
||||
useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-hist-w", String(Math.round(histW))); } catch {} }, [histW]);
|
||||
|
||||
function startResize(e: ReactMouseEvent, kind: "left" | "sync") {
|
||||
function startResize(e: ReactMouseEvent, kind: "left" | "sync" | "hist") {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startLeft = leftW, startSync = syncW;
|
||||
const startX = e.clientX, startLeft = leftW, startSync = syncW, startHist = histW;
|
||||
const move = (ev: MouseEvent) => {
|
||||
if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
|
||||
else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620)));
|
||||
else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
|
||||
};
|
||||
const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); };
|
||||
@ -340,6 +362,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
|
||||
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
|
||||
setSelRows(new Set());
|
||||
setPreviewCL(null);
|
||||
try { setPending(await p4.changes()); } catch { setPending([]); }
|
||||
}
|
||||
|
||||
@ -461,6 +484,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
async function openChange(c: Change) {
|
||||
const n = c.change || "";
|
||||
setSelChange(n); // highlight the row immediately — no waiting for the load
|
||||
setHistFile(null); // leave any drilled-in file view
|
||||
setDetail(null);
|
||||
setDetailBusy(true);
|
||||
try { setDetail(await p4.describe(n)); }
|
||||
@ -508,6 +532,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
}
|
||||
function switchTab(t: Tab) {
|
||||
setTab(t);
|
||||
setHistFile(null);
|
||||
if (t === "history" && history.length === 0) loadHistory();
|
||||
}
|
||||
|
||||
@ -529,11 +554,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
|
||||
// build the solution found in the working folder (MSBuild) with live output
|
||||
async function buildSln() {
|
||||
// open the configuration picker (Development / Shipping / …) before building
|
||||
function startBuild() {
|
||||
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
|
||||
setBuildPick(true);
|
||||
}
|
||||
// build the solution (MSBuild) with the chosen UE configuration, live output
|
||||
async function buildSln(config: string) {
|
||||
setBuildPick(false);
|
||||
if (!slnPath) return;
|
||||
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
|
||||
try { await p4.buildSln(slnPath); }
|
||||
try { await p4.buildSln(slnPath, config); }
|
||||
catch { /* the overlay already shows the failure line */ }
|
||||
}
|
||||
|
||||
@ -574,11 +605,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try {
|
||||
const cl = await p4.commit(message, list);
|
||||
flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
|
||||
setDesc(""); setDescBody("");
|
||||
// keep the draft — a local commit is not final; it clears only on Submit (see pushAll / submitOne)
|
||||
await refresh();
|
||||
} catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
|
||||
// Ask the AI (OpenRouter) to draft a commit summary + description from the checked files.
|
||||
async function genAiSummary() {
|
||||
const picked = files.filter((f) => (f.change || "default") === "default" && checked.has(f.depotFile || ""));
|
||||
if (!picked.length) { flash(t("Select some files first."), true); return; }
|
||||
setAiBusy(true);
|
||||
try {
|
||||
const { summary, description } = await aiSummary(picked);
|
||||
if (summary) setDesc(summary);
|
||||
if (description) setDescBody(description);
|
||||
flash(t("AI drafted a commit message — review & edit before committing."));
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
flash(msg.includes("No OpenRouter API key") ? t("Set an OpenRouter API key in Settings → AI first.") : msg, true);
|
||||
} finally { setAiBusy(false); }
|
||||
}
|
||||
|
||||
// Submit / push: send all committed (pending) changelists to the server. (like git push)
|
||||
async function pushAll() {
|
||||
if (!pending.length) return;
|
||||
@ -591,11 +638,43 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
|
||||
}
|
||||
if (errors.length) flash(errors.join(" · "), true);
|
||||
else flash(t("Sent to the server: {n} changelist(s).", { n }));
|
||||
else { flash(t("Sent to the server: {n} changelist(s).", { n })); setDesc(""); setDescBody(""); } // draft done → clear on successful Submit
|
||||
await refresh();
|
||||
await loadHistory(); // the submit created a new submitted changelist — refresh History
|
||||
}
|
||||
|
||||
// Submit a single pending changelist to the server
|
||||
async function submitOne(change: string) {
|
||||
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.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); setDesc(""); setDescBody(""); await refresh(); await loadHistory(); } // draft clears on successful Submit
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
// Uncommit: move a changelist's files back into the working set (default)
|
||||
async function uncommit(fs: OpenedFile[]) {
|
||||
const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
|
||||
if (!dps.length) return;
|
||||
setBusy(true);
|
||||
try { await p4.reopenDefault(dps); flash(t("Moved back to working changes.")); await refresh(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
// Edit a pending changelist's description
|
||||
function editDesc(cl: Change) {
|
||||
setPrompt({
|
||||
title: t("Edit description"), label: t("Changelist #{n}", { n: cl.change || "" }), value: (cl.desc || "").trim(),
|
||||
onSave: async (v) => {
|
||||
setPrompt(null);
|
||||
if (!v.trim()) return;
|
||||
setBusy(true);
|
||||
try { await p4.setDesc(cl.change || "", v.trim()); flash(t("Description updated.")); await refresh(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
},
|
||||
});
|
||||
}
|
||||
function toggleCL(change: string) {
|
||||
setExpandedCL((prev) => { const n = new Set(prev); n.has(change) ? n.delete(change) : n.add(change); return n; });
|
||||
}
|
||||
|
||||
async function doRevert(file: OpenedFile) {
|
||||
const dp = file.depotFile || "";
|
||||
if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
|
||||
@ -610,7 +689,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const q = filter.toLowerCase();
|
||||
return uncommitted.filter((f) => (f.depotFile || "").toLowerCase().includes(q));
|
||||
}, [uncommitted, filter]);
|
||||
const selFile = sel != null ? view[sel] : undefined;
|
||||
// committed pending changelists with their files (grouped from p4 opened)
|
||||
const pendingCards = useMemo(() =>
|
||||
pending.map((cl) => ({
|
||||
cl,
|
||||
files: files.filter((f) => String(f.change || "") === String(cl.change || "")),
|
||||
})), [pending, files]);
|
||||
const selFile = previewCL ?? (sel != null ? view[sel] : undefined);
|
||||
const allChecked = view.length > 0 && view.every((f) => checked.has(f.depotFile || ""));
|
||||
function toggleAll() {
|
||||
setChecked((s) => {
|
||||
@ -633,6 +718,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
setSelRows(new Set([i]));
|
||||
}
|
||||
setSel(i);
|
||||
setPreviewCL(null); // show the working-set selection in the preview
|
||||
}
|
||||
// checkbox: if the row is part of a multi-selection, toggle the whole selection together
|
||||
function toggleCheck(i: number, dp: string) {
|
||||
@ -673,7 +759,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
|
||||
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
|
||||
Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }],
|
||||
Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("People & Roles…"), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
|
||||
};
|
||||
|
||||
@ -696,6 +782,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
))}
|
||||
</div>
|
||||
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
|
||||
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
|
||||
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
|
||||
<WinControls />
|
||||
</div>
|
||||
@ -716,7 +803,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
||||
onContextMenu={(e) => openCtx(e, [
|
||||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []),
|
||||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
|
||||
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
||||
{ label: t("Choose another folder…"), act: () => browseTo("") },
|
||||
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
|
||||
@ -752,11 +839,49 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<div className="filter">
|
||||
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
|
||||
</div>
|
||||
{pending.length > 0 && (
|
||||
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
|
||||
<span className="pushdot" />
|
||||
<span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
|
||||
<span className="pusharr">↑</span>
|
||||
{pendingCards.length > 0 && (
|
||||
<div className="pending">
|
||||
<div className="pending-h">
|
||||
<span className="pending-t"><span className="pushdot" />{t("Pending · ready to Submit")}</span>
|
||||
<button className="pushall" onClick={pushAll} title={t("Submit all to the server")}>{t("Submit all")} ↑</button>
|
||||
</div>
|
||||
{pendingCards.map(({ cl, files: cf }) => {
|
||||
const ch = cl.change || "";
|
||||
const open = expandedCL.has(ch);
|
||||
return (
|
||||
<div key={ch} className="clcard">
|
||||
<div className="clcard-h" onClick={() => toggleCL(ch)}>
|
||||
<span className={"cl-chev" + (open ? " open" : "")}>{I.chev}</span>
|
||||
<span className="cl-num">#{ch}</span>
|
||||
<span className="cl-desc">{(cl.desc || "").trim() || t("(no description)")}</span>
|
||||
<span className="cl-count">{t("{n} files", { n: cf.length })}</span>
|
||||
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<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("Uncommit (back to working)"), act: () => uncommit(cf) },
|
||||
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
|
||||
])} title={t("More")}>⋯</button>
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="cl-files">
|
||||
{cf.length === 0 && <div className="cl-empty">{t("(files are outside the current working folder)")}</div>}
|
||||
{cf.map((f) => {
|
||||
const sp = splitPath(f.depotFile);
|
||||
const st = statusOf(f.action);
|
||||
return (
|
||||
<div key={f.depotFile} className={"cl-file" + (previewCL?.depotFile === f.depotFile ? " sel" : "")} onClick={() => setPreviewCL(f)}>
|
||||
<span className={"stat " + st.cls}>{st.ch}</span>
|
||||
<span className="cl-fname"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{scanning && (
|
||||
@ -783,8 +908,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
|
||||
)}
|
||||
<div className="commit">
|
||||
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||
<div className="summrow">
|
||||
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||
<button className={"aibtn icon" + (aiBusy ? " busy" : "")} disabled={aiBusy || checked.size === 0} onClick={genAiSummary}
|
||||
title={t("Draft a commit message with AI from the selected files")}>
|
||||
{aiBusy ? <span className="ldr sm" /> : I.spark}
|
||||
</button>
|
||||
</div>
|
||||
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
|
||||
@ -804,9 +935,26 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "changes"
|
||||
? <Preview file={selFile} onRevert={doRevert} onFlash={flash} />
|
||||
: <ChangeDetail detail={detail} busy={detailBusy} />}
|
||||
{tab === "changes" ? (
|
||||
<Preview file={selFile} onRevert={doRevert} onFlash={flash} />
|
||||
) : (
|
||||
<div className={"histsplit" + (histFile ? "" : " solo")} style={{ "--hist-w": `${histW}px` } as CSSProperties}>
|
||||
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split />
|
||||
{histFile && (<>
|
||||
<div className="vhandle histh" title={t("Drag to resize")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget; el.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX, start = histW;
|
||||
document.body.classList.add("resizing");
|
||||
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
|
||||
const up = () => { el.releasePointerCapture(e.pointerId); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); document.body.classList.remove("resizing"); };
|
||||
el.addEventListener("pointermove", move); el.addEventListener("pointerup", up);
|
||||
}} />
|
||||
<Preview file={histFile} onRevert={doRevert} onFlash={flash} />
|
||||
</>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logOpen && <LogPanel logs={logs} onClose={() => setLogOpen(false)} onClear={() => setLogs([])} />}
|
||||
@ -834,10 +982,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
||||
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
|
||||
{showXfer && transfer && <TransferDialog transfer={transfer} />}
|
||||
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||
@ -936,6 +1087,7 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
|
||||
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
|
||||
<AiSettings />
|
||||
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
|
||||
</div>
|
||||
</>)}
|
||||
@ -955,6 +1107,155 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- avatar (initials, deterministic color, activity dot) ---------------- */
|
||||
function Avatar({ user, name, size = 30, act }: { user: string; name?: string; size?: number; act?: "active" | "recent" | "idle" }) {
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * 0.38), background: avatarColor(user || name || "?") }} title={name ? `${name} (${user})` : user}>
|
||||
{initials(name || "", user)}
|
||||
{act && <span className={"adot " + act} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- AI settings block (inside Settings modal) — localStorage-backed ---------------- */
|
||||
function AiSettings() {
|
||||
const [key, setKey] = useState(getKeyLocal());
|
||||
const [model, setModelVal] = useState(getModel());
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="ai-set">
|
||||
<div className="ai-set-h">{I.spark}<span>{t("AI commit messages")}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("OpenRouter key")}</span>
|
||||
<span className="keyfield">
|
||||
<input type={show ? "text" : "password"} className="kf-in" placeholder="sk-or-…" value={key}
|
||||
onChange={(e) => { setKey(e.target.value); setKeyLocal(e.target.value); }} />
|
||||
<button className="kf-eye" onClick={() => setShow((s) => !s)} title={show ? t("Hide") : t("Show")}>{show ? "🙈" : "👁"}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-row"><span className="rk">{t("Model")}</span>
|
||||
<input className="kf-in mono" value={model} placeholder="google/gemini-2.0-flash-001"
|
||||
onChange={(e) => { setModelVal(e.target.value); setModel(e.target.value); }} />
|
||||
</div>
|
||||
<div className="ai-hint">{t("Stored only on this machine. Get a key at openrouter.ai — a “:free” model costs nothing.")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */
|
||||
function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const [sel, setSel] = useState(""); // selected user id
|
||||
const [myGroups, setMyGroups] = useState<string[]>([]); // groups of the selected user
|
||||
const [gBusy, setGBusy] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [us, gs] = await Promise.all([p4.users(), p4.groups()]);
|
||||
if (!live) return;
|
||||
setUsers(us);
|
||||
setGroups(gs.map((g) => g.group || "").filter(Boolean));
|
||||
} catch (e) { if (live) setErr(String(e)); }
|
||||
finally { if (live) setBusy(false); }
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function selectUser(u: string) {
|
||||
setSel(u); setMyGroups([]); setGBusy(true);
|
||||
try { const g = await p4.groups(u); setMyGroups(g.map((x) => x.group || "").filter(Boolean)); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setGBusy(false); }
|
||||
}
|
||||
async function toggleGroup(group: string) {
|
||||
if (!sel) return;
|
||||
const isMember = myGroups.includes(group);
|
||||
setGBusy(true);
|
||||
try {
|
||||
await p4.groupMember(group, sel, !isMember);
|
||||
setMyGroups((cur) => isMember ? cur.filter((x) => x !== group) : [...cur, group]);
|
||||
onFlash(isMember ? t("Removed {u} from “{g}”.", { u: sel, g: group }) : t("Added {u} to “{g}”.", { u: sel, g: group }));
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
onFlash(/permission|protect|admin|super|not allowed/i.test(msg) ? t("You need admin rights on the server to change roles.") : msg, true);
|
||||
} finally { setGBusy(false); }
|
||||
}
|
||||
|
||||
const s = q.trim().toLowerCase();
|
||||
const filtered = users.filter((u) => !s || (u.User || "").toLowerCase().includes(s) || (u.FullName || "").toLowerCase().includes(s) || (u.Email || "").toLowerCase().includes(s));
|
||||
const selUser = users.find((u) => u.User === sel);
|
||||
const X = <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>;
|
||||
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.people}<h3>{t("People & Roles")}</h3>
|
||||
<span className="ph-sub">{t("{n} on this depot", { n: users.length })}</span>{X}</div>
|
||||
{err ? <div className="viewer-msg err" style={{ position: "static", padding: 24 }}>{err}</div>
|
||||
: busy ? <div className="viewer-msg" style={{ position: "static", padding: 28 }}><span className="ldr" />{t("Loading users…")}</div>
|
||||
: (
|
||||
<div className="ur-body">
|
||||
<div className="ur-list">
|
||||
<div className="ur-filter">{I.search}<input placeholder={t("Filter people…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="ur-scroll">
|
||||
{filtered.map((u) => {
|
||||
const act = activity(u.Access);
|
||||
return (
|
||||
<div key={u.User} className={"ur-row" + (sel === u.User ? " on" : "")} onClick={() => selectUser(u.User || "")}>
|
||||
<Avatar user={u.User || ""} name={u.FullName} act={act} />
|
||||
<span className="ur-meta">
|
||||
<span className="ur-name">{u.FullName || u.User}{u.User === me && <span className="ur-you">{t("you")}</span>}</span>
|
||||
<span className="ur-sub">{u.Email || u.User}</span>
|
||||
</span>
|
||||
<span className={"ur-actd " + act} title={act} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ur-detail">
|
||||
{!selUser ? <div className="viewer-msg" style={{ position: "static", padding: 28, flexDirection: "column" }}>{I.people}<span>{t("Pick a person to see and edit their roles.")}</span></div>
|
||||
: (<>
|
||||
<div className="ur-dhead">
|
||||
<Avatar user={selUser.User || ""} name={selUser.FullName} size={46} act={activity(selUser.Access)} />
|
||||
<div className="ur-dinfo">
|
||||
<div className="ur-dname">{selUser.FullName || selUser.User}</div>
|
||||
<div className="ur-dsub">{selUser.Email || "—"}{selUser.Access ? " · " + t("active {t}", { t: fmtTime(selUser.Access) }) : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ur-grouphd"><span>{t("Roles / groups")}</span>{gBusy && <span className="ldr sm" />}</div>
|
||||
<div className="ur-groups">
|
||||
{groups.length === 0 && <div className="ur-empty">{t("No groups defined on the server.")}</div>}
|
||||
{groups.map((g) => {
|
||||
const on = myGroups.includes(g);
|
||||
return (
|
||||
<button key={g} className={"ur-chip" + (on ? " on" : "")} disabled={gBusy} onClick={() => toggleGroup(g)}>
|
||||
<span className="urc-box">{on ? I.check : null}</span>{g}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ai-hint">{t("Toggling a group grants or revokes that role. Requires admin rights on the server.")}</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- right-click context menu ---------------- */
|
||||
function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: CtxItem[]; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
@ -1069,6 +1370,39 @@ function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* build configuration picker (Unreal: Development / Shipping / …) */
|
||||
function BuildPicker({ onPick, onClose }: { onPick: (config: string) => void; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
const opts: { config: string; name: string; desc: string }[] = [
|
||||
{ config: "Development Editor", name: "Development Editor", desc: t("Editor build for working in-engine (default)") },
|
||||
{ config: "Development", name: "Development", desc: t("Standalone game, dev config") },
|
||||
{ config: "DebugGame", name: "DebugGame", desc: t("Game with debuggable game code") },
|
||||
{ config: "Shipping", name: "Shipping", desc: t("Optimized release build") },
|
||||
];
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.hammer}<h3>{t("Build configuration")}</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="picker-list">
|
||||
{opts.map((o) => (
|
||||
<div key={o.config} className="picker-item" onClick={() => onPick(o.config)}>
|
||||
<span className="pi-ic">{I.hammer}</span>
|
||||
<span className="pi-body"><span className="pi-name">{o.name} · Win64</span><span className="pi-sub">{o.desc}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="picker-foot">{t("Builds via MSBuild → UnrealBuildTool")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* minimized build indicator — small floating chip, click to expand */
|
||||
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
|
||||
return (
|
||||
@ -1082,6 +1416,31 @@ function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok:
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- text prompt (edit description) ---------------- */
|
||||
function TextPrompt({ title, label, value, onSave, onClose }: { title: string; label: string; value: string; onSave: (v: string) => void; onClose: () => void }) {
|
||||
const [v, setV] = useState(value);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(v); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [v]); // eslint-disable-line
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{title}</h3>
|
||||
<div className="prompt-label">{label}</div>
|
||||
<textarea ref={ref} className="prompt-input" value={v} onChange={(e) => setV(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="mbtn ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={() => onSave(v)}>{t("Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- confirm modal ---------------- */
|
||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
@ -1110,7 +1469,7 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string
|
||||
|
||||
/* ---------------- change detail (History right panel) — virtualized ---------------- */
|
||||
const CROW = 52;
|
||||
function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolean }) {
|
||||
function ChangeDetail({ detail, busy, onOpen, selected, split }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const [h, setH] = useState(500);
|
||||
@ -1126,8 +1485,9 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
|
||||
// jump back to top whenever a different changelist loads
|
||||
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
|
||||
|
||||
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
|
||||
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
|
||||
const cls = "right" + (split ? " histlist" : "");
|
||||
if (busy) return <div className={cls}><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
|
||||
if (!detail) return <div className={cls}><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
|
||||
|
||||
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
|
||||
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
|
||||
@ -1136,10 +1496,13 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
|
||||
const f = fileRows[i];
|
||||
const { name, dir } = splitPath(f.depotFile);
|
||||
const st = statusOf(f.action);
|
||||
const key = `${f.depotFile}#${f.rev}`;
|
||||
rows.push(
|
||||
<div key={i} className="crow" style={{ top: i * CROW }}>
|
||||
<div key={i} className={"crow click" + (selected === key ? " sel" : "")} style={{ top: i * CROW }}
|
||||
onClick={() => onOpen?.({ depotFile: f.depotFile, action: f.action, rev: f.rev, type: "", change: detail.change, _spec: key })}>
|
||||
<span className={"stat " + st.cls}>{st.ch}</span>
|
||||
<span className="hbody"><span className="hdesc" style={{ WebkitLineClamp: 1 }}>{name}</span><span className="hmeta">{dir}#{f.rev}</span></span>
|
||||
<span className="crow-go">{I.chevron}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1165,10 +1528,10 @@ function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Cha
|
||||
<div className="files" style={{ position: "relative" }}>
|
||||
{history.map((c) => (
|
||||
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
|
||||
<span className="hnum">#{c.change}</span>
|
||||
<Avatar user={c.user || "?"} size={26} />
|
||||
<span className="hbody">
|
||||
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
|
||||
<span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
|
||||
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -1248,10 +1611,13 @@ function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContex
|
||||
function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) {
|
||||
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
|
||||
const dp = file.depotFile || "";
|
||||
const spec = file._spec || ""; // set → read-only view of a submitted revision
|
||||
const hist = !!spec;
|
||||
const { name, dir } = splitPath(dp);
|
||||
const st = statusOf(file.action);
|
||||
const kind = kindOf(name);
|
||||
const rev = file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
|
||||
const rev = hist ? "#" + (file.rev || "?")
|
||||
: file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
|
||||
|
||||
return (
|
||||
<div className="right">
|
||||
@ -1259,20 +1625,20 @@ function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f:
|
||||
<span className={"stat " + st.cls} style={{ width: 26, height: 26, fontSize: 13 }}>{st.ch}</span>
|
||||
<span className="ttl">
|
||||
<span className="n">{name} <span>— {dir}</span></span>
|
||||
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
|
||||
<span className="m">{file.action} · <span className="rev">{rev}</span>{file.type ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""}</span>
|
||||
</span>
|
||||
<span className="tools">
|
||||
{kind === "code" && (
|
||||
{kind === "code" && !hist && (
|
||||
<span className="gbtn vsc" onClick={async () => { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
|
||||
{I.vscode}{t("Edit in VS Code")}
|
||||
</span>
|
||||
)}
|
||||
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
|
||||
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{kind === "model" ? <ModelViewer depot={dp} name={name} />
|
||||
: kind === "image" ? <ImageViewer depot={dp} name={name} />
|
||||
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
|
||||
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
|
||||
: kind === "uasset" ? <UassetPreview file={file} />
|
||||
: <CodeView file={file} />}
|
||||
</div>
|
||||
@ -1283,13 +1649,13 @@ function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
|
||||
}
|
||||
|
||||
/* ---------------- real image viewer ---------------- */
|
||||
function ImageViewer({ depot, name }: { depot: string; name: string }) {
|
||||
function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [dim, setDim] = useState("");
|
||||
useEffect(() => {
|
||||
let u = ""; let live = true;
|
||||
p4.readDepot(depot).then((buf) => {
|
||||
(spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
|
||||
if (!live) return;
|
||||
const ext = (name.split(".").pop() || "png").toLowerCase();
|
||||
const mime = ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}`;
|
||||
@ -1297,7 +1663,7 @@ function ImageViewer({ depot, name }: { depot: string; name: string }) {
|
||||
setUrl(u);
|
||||
}).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; if (u) URL.revokeObjectURL(u); };
|
||||
}, [depot, name]);
|
||||
}, [depot, name, spec]);
|
||||
return (
|
||||
<div className="asset">
|
||||
<div className="stage">
|
||||
@ -1323,14 +1689,14 @@ function UassetPreview({ file }: { file: OpenedFile }) {
|
||||
useEffect(() => {
|
||||
let live = true; let url = "";
|
||||
setThumb(null); setTried(false);
|
||||
p4.readDepot(dp).then((buf) => {
|
||||
fileBytes(file).then((buf) => {
|
||||
if (!live) return;
|
||||
const png = extractPng(new Uint8Array(buf));
|
||||
if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
|
||||
setTried(true);
|
||||
}).catch(() => live && setTried(true));
|
||||
return () => { live = false; if (url) URL.revokeObjectURL(url); };
|
||||
}, [dp]);
|
||||
}, [dp, file._spec]);
|
||||
|
||||
return (
|
||||
<div className="asset">
|
||||
|
||||
@ -48,6 +48,7 @@ const MAX = 800_000; // don't try to render/highlight enormous files
|
||||
// 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);
|
||||
@ -58,9 +59,11 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setDiff(null); setCode(null); setErr(""); setTooBig(false);
|
||||
// edits show a diff; adds have none, so fetch it but don't depend on it
|
||||
p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
|
||||
p4.readDepot(dp).then((buf) => {
|
||||
// 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);
|
||||
@ -69,7 +72,7 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
||||
}).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; };
|
||||
}, [dp]);
|
||||
}, [dp, spec]);
|
||||
|
||||
const lang = langOf(name);
|
||||
const highlighted = useMemo(() => {
|
||||
|
||||
@ -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
@ -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";
|
||||
}
|
||||
50
src/i18n.ts
@ -74,10 +74,43 @@ const D: Record<string, Tr> = {
|
||||
"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 d’abord 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 d’abord 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: "L’IA 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 d’abord 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 d’Unreal…", es: "Iniciando Unreal…" },
|
||||
|
||||
@ -86,6 +119,21 @@ const D: Record<string, Tr> = {
|
||||
"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. C’est 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 c’est 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" },
|
||||
@ -206,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 l’historique…", es: "Cargando historial…" },
|
||||
"No submitted changes.": { ru: "Нет засабмиченных изменений.", de: "Keine übermittelten Änderungen.", fr: "Aucune modification envoyée.", es: "No hay cambios enviados." },
|
||||
|
||||
26
src/p4.ts
@ -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 }),
|
||||
@ -74,7 +83,7 @@ export const p4 = {
|
||||
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) => invoke<string>("build_sln", { path }),
|
||||
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> => {
|
||||
@ -84,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 {
|
||||
|
||||