diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f556f0..fea5ecc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) -> Result { + 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 { + 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( @@ -895,6 +958,8 @@ pub fn run() { p4_submit, p4_commit, p4_submit_change, + p4_reopen_default, + p4_set_desc, p4_add, p4_edit, p4_delete, diff --git a/src/App.css b/src/App.css index ab66e26..17c6a21 100644 --- a/src/App.css +++ b/src/App.css @@ -122,6 +122,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)} diff --git a/src/App.tsx b/src/App.tsx index 6651a6b..4e33814 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -276,6 +276,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [checked, setChecked] = useState>(new Set()); const [sel, setSel] = useState(null); // primary row (preview + shift anchor) const [selRows, setSelRows] = useState>(new Set()); // multi-selection (Shift/Ctrl-click) + const [previewCL, setPreviewCL] = useState(null); // preview override (pending-CL file) + const [expandedCL, setExpandedCL] = useState>(new Set()); // expanded pending changelist cards + const [prompt, setPrompt] = useState void }>(null); const [filter, setFilter] = useState(""); const [desc, setDesc] = useState(""); // commit summary (first line) const [descBody, setDescBody] = useState(""); // extended description (optional) @@ -340,6 +343,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([]); } } @@ -596,6 +600,38 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set 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 })); await refresh(); await loadHistory(); } + 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 +646,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 +675,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) { @@ -752,11 +795,49 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{I.search} { setFilter(e.target.value); setSelRows(new Set()); }} />
- {pending.length > 0 && ( -
- - {t("{n} changelist(s) committed — ready to Submit", { n: pending.length })} - + {pendingCards.length > 0 && ( +
+
+ {t("Pending · ready to Submit")} + +
+ {pendingCards.map(({ cl, files: cf }) => { + const ch = cl.change || ""; + const open = expandedCL.has(ch); + return ( +
+
toggleCL(ch)}> + {I.chev} + #{ch} + {(cl.desc || "").trim() || t("(no description)")} + {t("{n} files", { n: cf.length })} + e.stopPropagation()}> + + + +
+ {open && ( +
+ {cf.length === 0 &&
{t("(files are outside the current working folder)")}
} + {cf.map((f) => { + const sp = splitPath(f.depotFile); + const st = statusOf(f.action); + return ( +
setPreviewCL(f)}> + {st.ch} + {sp.name}{sp.dir} +
+ ); + })} +
+ )} +
+ ); + })}
)} {scanning && ( @@ -837,6 +918,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {ask && { ask.resolve(v); setAsk(null); }} />} + {prompt && setPrompt(null)} />} {showXfer && transfer && } {buildOpen && !buildMin && setBuildMin(true)} onClose={() => setBuildOpen(false)} />} {buildOpen && buildMin && setBuildMin(false)} onClose={() => setBuildOpen(false)} />} @@ -1082,6 +1164,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(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 ( +
+
e.stopPropagation()}> +

{title}

+
{label}
+