Release 0.2.2 — browse submitted changelists, People & Roles, AI summaries

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 18:18:07 +03:00
parent 168b6afab3
commit 8e2cac4437
12 changed files with 613 additions and 46 deletions

View File

@ -6,7 +6,7 @@ use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
/// App handle stashed at startup so the low-level p4 helpers can emit a live
/// command log to the frontend (a Perforce-style "Log" panel).
@ -683,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(())
@ -917,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> {
@ -939,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()
@ -989,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!())