Jobs: list / create / edit + attach to changelist
- Tools -> Jobs: browse/filter jobs, create/edit job spec - Pending changelist menu -> Attach job... (p4 fix) - Backend p4 jobs, fix, job -o/-i Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1867,6 +1867,59 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
|
||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// List jobs on the server (defect/task tracking).
|
||||
#[tauri::command]
|
||||
async fn p4_jobs(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["jobs", "-m", "300"])
|
||||
}
|
||||
|
||||
/// Link a job to a pending changelist (`p4 fix -c <change> <job>`).
|
||||
#[tauri::command]
|
||||
async fn p4_fix(state: State<'_, AppState>, job: String, change: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let job = job.trim();
|
||||
let change = change.trim();
|
||||
if job.is_empty() || change.is_empty() {
|
||||
return Err("Job and changelist are required".into());
|
||||
}
|
||||
run_text(&conn, &["fix", "-c", change, job])
|
||||
}
|
||||
|
||||
/// Get a job spec as editable text (unknown name → new job template).
|
||||
#[tauri::command]
|
||||
async fn p4_job_spec(state: State<'_, AppState>, job: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let j = job.trim();
|
||||
if j.is_empty() {
|
||||
run_text(&conn, &["job", "-o"]) // template for a new job
|
||||
} else {
|
||||
run_text(&conn, &["job", "-o", j])
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a job spec (`p4 job -i`), creating it if new.
|
||||
#[tauri::command]
|
||||
async fn p4_job_save(state: State<'_, AppState>, spec: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let mut child = p4_cmd(&conn, false)
|
||||
.args(["job", "-i"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start p4: {e}"))?;
|
||||
if let Some(si) = child.stdin.as_mut() {
|
||||
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = decode(&out.stderr);
|
||||
return Err(if err.trim().is_empty() { decode(&out.stdout).trim().to_string() } else { err.trim().to_string() });
|
||||
}
|
||||
Ok(decode(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// List streams on the server.
|
||||
#[tauri::command]
|
||||
async fn p4_streams(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
@ -2156,6 +2209,10 @@ pub fn run() {
|
||||
p4_streams,
|
||||
p4_switch_stream,
|
||||
p4_stream_integ,
|
||||
p4_jobs,
|
||||
p4_fix,
|
||||
p4_job_spec,
|
||||
p4_job_save,
|
||||
p4_client_spec,
|
||||
p4_client_save,
|
||||
uasset_thumbnail,
|
||||
|
||||
67
src/App.tsx
67
src/App.tsx
@ -313,6 +313,7 @@ type ModalState =
|
||||
| { kind: "labels" }
|
||||
| { kind: "branch" }
|
||||
| { kind: "streams" }
|
||||
| { kind: "jobs" }
|
||||
| { kind: "client"; name: string; isNew: boolean }
|
||||
| { kind: "blame"; spec: string; name: string }
|
||||
| { kind: "diff"; title: string; text: string }
|
||||
@ -899,6 +900,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } // draft clears on successful Submit
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
function attachJob(change: string) {
|
||||
setPrompt({ title: t("Attach job to #{n}", { n: change }), label: t("Job ID"), value: "", onSave: async (v) => {
|
||||
setPrompt(null); const j = v.trim(); if (!j) return;
|
||||
try { await p4.fix(j, change); flash(t("Job {j} linked to #{n}.", { j, n: change })); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
} });
|
||||
}
|
||||
async function shelveCL(change: string) {
|
||||
setBusy(true);
|
||||
try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); }
|
||||
@ -1072,7 +1080,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
|
||||
{ label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
|
||||
],
|
||||
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
|
||||
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
|
||||
Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }],
|
||||
};
|
||||
|
||||
@ -1184,6 +1192,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}>↑</button>
|
||||
<button className="cl-more" onClick={(e) => openCtx(e, [
|
||||
{ label: t("Edit description"), act: () => editDesc(cl) },
|
||||
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
|
||||
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
|
||||
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
|
||||
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
|
||||
@ -1329,6 +1338,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
|
||||
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
|
||||
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
|
||||
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
|
||||
@ -2011,6 +2021,61 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- jobs (list / create / edit) ---------------- */
|
||||
function JobsModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [jobs, setJobs] = useState<{ Job?: string; Status?: string; Description?: string; User?: string }[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [edit, setEdit] = useState<null | { spec: string; isNew: boolean }>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const load = () => { setBusy(true); p4.jobs().then(setJobs).catch((e) => { onFlash(String(e), true); setJobs([]); }).finally(() => setBusy(false)); };
|
||||
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && (edit ? setEdit(null) : onClose()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [edit]); // eslint-disable-line
|
||||
async function openEdit(job: string) {
|
||||
try { const spec = await p4.jobSpec(job); setEdit({ spec, isNew: !job }); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
async function saveEdit() {
|
||||
if (!edit) return;
|
||||
setSaving(true);
|
||||
try { await p4.jobSave(edit.spec); onFlash(t("Job saved.")); setEdit(null); load(); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const s = q.trim().toLowerCase();
|
||||
const rows = jobs.filter((j) => !s || (j.Job || "").toLowerCase().includes(s) || (j.Description || "").toLowerCase().includes(s) || (j.User || "").toLowerCase().includes(s));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.log}<h3>{t("Jobs")}</h3><span className="ph-sub">{edit ? (edit.isNew ? t("New job") : t("Edit job")) : t("{n} jobs", { n: jobs.length })}</span>
|
||||
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
{edit ? (<>
|
||||
<div className="tm-hint">{t("Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.")}</div>
|
||||
<textarea className="code-edit" value={edit.spec} spellCheck={false} onChange={(e) => setEdit({ ...edit, spec: e.target.value })} />
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={() => setEdit(null)} disabled={saving}>{t("Back")}</button>
|
||||
<button className="mbtn primary" onClick={saveEdit} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||
</div>
|
||||
</>) : (<>
|
||||
<div className="rslv-bar">
|
||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter jobs…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<button className="mbtn primary" onClick={() => openEdit("")}>{t("New job")}</button>
|
||||
</div>
|
||||
<div className="srch-list">
|
||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
|
||||
{!busy && rows.length === 0 && <div className="ur-empty">{t("No jobs.")}</div>}
|
||||
{rows.map((j) => (
|
||||
<div key={j.Job} className="srch-row" onClick={() => openEdit(j.Job || "")} style={{ cursor: "pointer" }}>
|
||||
<span className="srch-body"><span className="n">{j.Job} <span style={{ color: "var(--faint)", fontWeight: 400 }}>· {j.Status}</span></span><span className="p">{(j.Description || "").trim().slice(0, 120)}{j.User ? " — " + j.User : ""}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- streams (list / switch / merge down / copy up) ---------------- */
|
||||
function StreamsModal({ current, onClose, onFlash, onSwitch, onInteg }: { current: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSwitch: (s: string) => void; onInteg: (s: string, dir: "down" | "up") => void }) {
|
||||
const [streams, setStreams] = useState<{ Stream?: string; Name?: string; Type?: string; Parent?: string }[]>([]);
|
||||
|
||||
14
src/i18n.ts
14
src/i18n.ts
@ -452,6 +452,20 @@ const D: Record<string, Tr> = {
|
||||
"Switch": { ru: "Переключить", de: "Wechseln", fr: "Basculer", es: "Cambiar" },
|
||||
"Switched to stream {s}": { ru: "Переключено на стрим {s}", de: "Zu Stream {s} gewechselt", fr: "Basculé sur le stream {s}", es: "Cambiado al stream {s}" },
|
||||
"{d} opened into a pending changelist — resolve & submit. {out}": { ru: "{d}: файлы открыты в pending-changelist — разреши и сабмить. {out}", de: "{d} in einen ausstehenden Changelist geöffnet — auflösen & übermitteln. {out}", fr: "{d} ouvert dans un changelist en attente — résolvez et soumettez. {out}", es: "{d} abierto en un changelist pendiente — resuelve y envía. {out}" },
|
||||
"Jobs…": { ru: "Задачи (jobs)…", de: "Jobs…", fr: "Jobs…", es: "Jobs…" },
|
||||
"Jobs": { ru: "Задачи (jobs)", de: "Jobs", fr: "Jobs", es: "Jobs" },
|
||||
"{n} jobs": { ru: "задач: {n}", de: "{n} Jobs", fr: "{n} jobs", es: "{n} jobs" },
|
||||
"Filter jobs…": { ru: "Фильтр задач…", de: "Jobs filtern…", fr: "Filtrer les jobs…", es: "Filtrar jobs…" },
|
||||
"New job": { ru: "Новая задача", de: "Neuer Job", fr: "Nouveau job", es: "Nuevo job" },
|
||||
"Edit job": { ru: "Редактирование задачи", de: "Job bearbeiten", fr: "Modifier le job", es: "Editar job" },
|
||||
"Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.": { ru: "Отредактируй спеку задачи. Поля зависят от jobspec сервера. Строки с # — комментарии.", de: "Bearbeite die Job-Spezifikation. Die Felder hängen vom Jobspec des Servers ab. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec du job. Les champs dépendent du jobspec du serveur. Les lignes avec # sont des commentaires.", es: "Edita la especificación del job. Los campos dependen del jobspec del servidor. Las líneas con # son comentarios." },
|
||||
"Job saved.": { ru: "Задача сохранена.", de: "Job gespeichert.", fr: "Job enregistré.", es: "Job guardado." },
|
||||
"No jobs.": { ru: "Задач нет.", de: "Keine Jobs.", fr: "Aucun job.", es: "Sin jobs." },
|
||||
"Back": { ru: "Назад", de: "Zurück", fr: "Retour", es: "Atrás" },
|
||||
"Attach job…": { ru: "Привязать задачу…", de: "Job anhängen…", fr: "Associer un job…", es: "Adjuntar job…" },
|
||||
"Attach job to #{n}": { ru: "Привязать задачу к #{n}", de: "Job an #{n} anhängen", fr: "Associer un job à #{n}", es: "Adjuntar job a #{n}" },
|
||||
"Job ID": { ru: "ID задачи", de: "Job-ID", fr: "ID du job", es: "ID del job" },
|
||||
"Job {j} linked to #{n}.": { ru: "Задача {j} привязана к #{n}.", de: "Job {j} mit #{n} verknüpft.", fr: "Job {j} associé à #{n}.", es: "Job {j} vinculado a #{n}." },
|
||||
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
|
||||
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
|
||||
};
|
||||
|
||||
@ -71,6 +71,10 @@ export const p4 = {
|
||||
streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"),
|
||||
switchStream: (stream: string) => invoke<P4Info>("p4_switch_stream", { stream }),
|
||||
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
|
||||
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
|
||||
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
|
||||
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
|
||||
jobSave: (spec: string) => invoke<string>("p4_job_save", { spec }),
|
||||
diff: (file: string) => invoke<string>("p4_diff", { file }),
|
||||
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
|
||||
submit: (description: string, files: string[]) =>
|
||||
|
||||
Reference in New Issue
Block a user