Files
Bonchellon 6c16d4a4f4 Hardening + collaboration features from full app review
Security and robustness (backend):
- Strict CSP (was null); confine read_file/launch_file/ue_log to the client root
- p4 trust without -f: surface fingerprint CHANGES instead of auto-accepting (MITM)
- stdin=null by default + non-interactive P4EDITOR so terminal commands never hang
- 64 MB read caps on read_depot/read_file/print_depot (no OOM on huge .uasset/.pak)
- Recover from mutex poisoning instead of crashing every later p4 call
- Decode p4 output as UTF-8 then cp1251 (Cyrillic paths no longer corrupt)
- p4_changes never deletes changelists that hold shelved files
- Validate changelist number in p4_undo_change

Bug fixes (frontend):
- Focus-refresh preserves the checkbox selection (never re-checks deselected files)
- Staleness guards on refresh/describe/AI-summary/user-groups (no cross-selection races)
- Real F5/Ctrl+R/G/S/B handlers; match e.code so Ctrl+backquote works on RU layout
- Confirm dialog no longer fires on a stray global Enter
- Panel/dock/history drags end on window blur (no stuck resize)
- Commit draft clears on Submit only if unchanged since committing
- Summary/IDE buttons gated to real source files (not .pak/.dll/binaries)

New features:
- Exclusive lock/unlock plus held-by/locked-by indicators (p4 opened -a)
- Shelve/unshelve/delete-shelf on pending changelists
- Resolve UI (auto-merge / accept yours / accept theirs, per file or all) + banner
- Diff a history revision against the previous one; blame/annotate view
- Depot file search (Tools -> Search depot)
- Move opened files between pending changelists
- New-submit toasts from teammates + offline banner with auto-reconnect

Also: bake OpenRouter key at build time (git-ignored), drop AI settings UI,
IDE picker (detect installed editors), AI replies in the UI language,
persistent code summaries, metadata/CSP polish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:26:57 +03:00

118 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// OpenRouter-powered commit summary + small avatar helpers.
import { p4, OpenedFile, statusOf } from "./p4";
import { LANG } from "./i18n";
// AI replies follow the app's UI language
const LANG_NAMES: Record<string, string> = { en: "English", ru: "Russian", de: "German", fr: "French", es: "Spanish" };
function langName(): string { return LANG_NAMES[LANG] || "English"; }
// fixed model — no user-facing AI settings
export const DEFAULT_MODEL = "openai/gpt-4o-mini";
// the key is baked into the binary at build time (src-tauri/openrouter.key)
export async function resolveKey(): Promise<string> {
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 baked into this build");
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: DEFAULT_MODEL,
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. Write the entire reply in ${langName()}.` },
{ 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 };
}
// briefly explain what a source file does (24 sentences, plain text)
export async function aiExplainCode(name: string, code: string): Promise<string> {
const key = await resolveKey();
if (!key) throw new Error("No OpenRouter API key baked into this build");
const snippet = code.slice(0, 14000); // cap the payload
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: DEFAULT_MODEL,
max_tokens: 260,
temperature: 0.2,
messages: [
{ role: "system", content: `You explain source files for a Perforce/Unreal C++ (and general game) project. Given one file, reply with a SHORT plain-text summary (24 sentences) of what it does and its role in the project. No markdown, no code fences, no headings, no bullet points — a single tight paragraph. Write the entire reply in ${langName()}.` },
{ role: "user", content: `File: ${name}\n\n${snippet}` },
],
}),
});
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");
return content;
}
// --- persistent cache of AI code summaries (removed only via the × button) ---
const EXPL_KEY = "exd-explain";
type ExplEntry = { k: string; v: string };
function loadExpl(): ExplEntry[] {
try { const r = localStorage.getItem(EXPL_KEY); const l = r ? JSON.parse(r) : []; return Array.isArray(l) ? l : []; } catch { return []; }
}
function storeExpl(list: ExplEntry[]) { try { localStorage.setItem(EXPL_KEY, JSON.stringify(list)); } catch {} }
export function getExplain(key: string): string | null {
return loadExpl().find((e) => e.k === key)?.v ?? null;
}
export function saveExplain(key: string, text: string) {
const list = loadExpl().filter((e) => e.k !== key);
list.push({ k: key, v: text });
storeExpl(list.slice(-80)); // keep the 80 most recent
}
export function dropExplain(key: string) {
storeExpl(loadExpl().filter((e) => e.k !== key));
}
// --- 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";
}