v0.3.4 — workspace View include/exclude tree + fix oversized button icons
- Edit-workspace dialog gets a P4V-style folder tree with tri-state include/ exclude checkboxes (lazy-loaded via p4 dirs). Toggling writes the depot→client View mapping (`//depot/Foo/...` or `-//depot/Foo/...`) — i.e. what syncs and what doesn't. Tree/Text mode toggle; after Save it offers Get Latest. - Fix: .mbtn buttons weren't flex and didn't cap their <svg>, so an icon inside (e.g. the lock on "Add recommended Unreal rules") blew up to fill the button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
119
src/App.tsx
119
src/App.tsx
@ -1682,7 +1682,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{modal?.kind === "folderprops" && <FolderPropsModal path={modal.path} name={modal.name} onClose={() => setModal(null)} onReveal={reveal} onRename={() => { const p = modal.path; setModal(null); renameFolder(p); }} onDelete={() => { const p = modal.path; setModal(null); deleteFolder(p); }} onCopy={(p) => copyText(p, t("Path"))} />}
|
||||
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
||||
{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 === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={async (c) => { const wasNew = modal.isNew; setModal(null); if (wasNew) { switchWorkspace(c); return; } await refresh(); if (await confirm({ title: t("Workspace mapping changed"), body: t("Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync."), confirm: t("Get Latest") })) getLatest(); }} />}
|
||||
{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)} />}
|
||||
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
|
||||
@ -2972,10 +2972,113 @@ function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onC
|
||||
}
|
||||
|
||||
/* ---------------- workspace (client) spec editor ---------------- */
|
||||
/* ---------------- workspace View (client mapping) — P4V-style include/exclude tree ---------------- */
|
||||
type ViewRule = { exclude: boolean; depot: string; client: string };
|
||||
|
||||
function clientNameOf(spec: string): string {
|
||||
const m = spec.match(/^Client:\s*(\S+)/m);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
// parse the View: block of a client spec into ordered mapping rules
|
||||
function parseViewRules(spec: string): ViewRule[] {
|
||||
const rules: ViewRule[] = [];
|
||||
let inView = false;
|
||||
for (const ln of spec.split(/\r?\n/)) {
|
||||
if (/^View:/.test(ln)) { inView = true; continue; }
|
||||
if (!inView) continue;
|
||||
if (/^\S/.test(ln)) break; // next section starts at column 0
|
||||
const s = ln.trim();
|
||||
if (!s || s.startsWith("#")) continue;
|
||||
const toks = s.match(/"[^"]*"|\S+/g) || [];
|
||||
const [d0, c0] = toks;
|
||||
if (!d0 || !c0) continue;
|
||||
let depot = d0.replace(/^"|"$/g, "");
|
||||
const client = c0.replace(/^"|"$/g, "");
|
||||
let exclude = false;
|
||||
if (depot.startsWith("-")) { exclude = true; depot = depot.slice(1); }
|
||||
else if (depot.startsWith("+")) { depot = depot.slice(1); }
|
||||
rules.push({ exclude, depot, client });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
const quoteIf = (s: string) => (/\s/.test(s) ? `"${s}"` : s);
|
||||
// write the given rules back into the spec's View: block (replacing the old one)
|
||||
function buildViewSpec(spec: string, rules: ViewRule[]): string {
|
||||
const lines = spec.split(/\r?\n/);
|
||||
const out: string[] = [];
|
||||
const viewLines = () => rules.map((r) => `\t${(r.exclude ? "-" : "") + quoteIf(r.depot)} ${quoteIf(r.client)}`);
|
||||
let inView = false, wrote = false;
|
||||
for (const ln of lines) {
|
||||
if (/^View:/.test(ln)) { out.push("View:"); out.push(...viewLines()); inView = true; wrote = true; continue; }
|
||||
if (inView) { if (/^\S/.test(ln)) { inView = false; out.push(ln); } continue; }
|
||||
out.push(ln);
|
||||
}
|
||||
if (!wrote) { out.push("View:"); out.push(...viewLines()); }
|
||||
return out.join("\n");
|
||||
}
|
||||
const rulePrefix = (depot: string) => depot.replace(/\.\.\.$/, "");
|
||||
// is folder `depot` currently mapped in, and does any descendant rule differ (mixed)?
|
||||
function nodeState(depot: string, rules: ViewRule[]): { included: boolean; mixed: boolean } {
|
||||
const dSlash = depot.endsWith("/") ? depot : depot + "/";
|
||||
let included = false, mixed = false;
|
||||
for (const r of rules) {
|
||||
const p = rulePrefix(r.depot);
|
||||
if (dSlash.startsWith(p)) included = !r.exclude; // rule covers this whole folder
|
||||
}
|
||||
for (const r of rules) {
|
||||
const p = rulePrefix(r.depot);
|
||||
if (p.startsWith(dSlash) && p !== dSlash && !r.exclude !== included) mixed = true; // a descendant differs
|
||||
}
|
||||
return { included, mixed };
|
||||
}
|
||||
// include or exclude a folder: drop prior self/descendant rules, append the new one
|
||||
function setNode(rules: ViewRule[], depot: string, include: boolean, client: string): ViewRule[] {
|
||||
const dSlash = depot + "/";
|
||||
const rel = depot.replace(/^\/\/[^/]+\//, "");
|
||||
const clientPat = `//${client}/${rel}/...`;
|
||||
const kept = rules.filter((r) => { const p = rulePrefix(r.depot); return !(p === dSlash || p.startsWith(dSlash)); });
|
||||
kept.push({ exclude: !include, depot: `${dSlash}...`, client: clientPat });
|
||||
return kept;
|
||||
}
|
||||
|
||||
function ViewTreeNode({ depot, name, depth, rules, onToggle }: { depot: string; name: string; depth: number; rules: ViewRule[]; onToggle: (depot: string, include: boolean) => void }) {
|
||||
const [open, setOpen] = useState(depth === 0);
|
||||
const [kids, setKids] = useState<{ depot: string; name: string }[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!open || kids !== null) return;
|
||||
setLoading(true);
|
||||
p4.dirs(depot)
|
||||
.then((ds) => setKids(ds.filter((d) => d.dir).map((d) => ({ depot: d.dir as string, name: (d.dir as string).split("/").filter(Boolean).pop() || (d.dir as string) }))))
|
||||
.catch(() => setKids([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]); // eslint-disable-line
|
||||
const st = nodeState(depot, rules);
|
||||
return (
|
||||
<div className="vt-node">
|
||||
<div className="vt-row" style={{ paddingLeft: 8 + depth * 15 }}>
|
||||
<span className="vt-chev" onClick={() => setOpen((o) => !o)}>
|
||||
<svg viewBox="0 0 24 24" style={{ transform: open ? "rotate(90deg)" : "none" }} fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</span>
|
||||
<span className={"vt-chk" + (st.mixed ? " mixed" : st.included ? " on" : "")} onClick={() => onToggle(depot, !st.included)} title={st.included ? t("Included — click to exclude (won’t be synced)") : t("Excluded — click to include (will be synced)")}>
|
||||
{st.mixed ? <svg viewBox="0 0 24 24" fill="none"><path d="M6 12h12" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" /></svg>
|
||||
: st.included ? <svg viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg> : null}
|
||||
</span>
|
||||
<span className="vt-ic">{I.folder}</span>
|
||||
<span className="vt-name">{name}</span>
|
||||
</div>
|
||||
{open && kids && kids.map((k) => <ViewTreeNode key={k.depot} depot={k.depot} name={k.name} depth={depth + 1} rules={rules} onToggle={onToggle} />)}
|
||||
{open && loading && <div className="vt-loading" style={{ paddingLeft: 8 + (depth + 1) * 15 }}><span className="ldr sm" /></div>}
|
||||
{open && kids && kids.length === 0 && !loading && <div className="vt-empty" style={{ paddingLeft: 8 + (depth + 1) * 15 }}>{t("(no subfolders)")}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
|
||||
const [spec, setSpec] = useState("");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mode, setMode] = useState<"tree" | "text">("tree");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
|
||||
@ -2992,15 +3095,25 @@ function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: str
|
||||
} catch (e) { onFlash(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const rules = mode === "tree" ? parseViewRules(spec) : [];
|
||||
const toggleNode = (depot: string, include: boolean) => setSpec((s) => buildViewSpec(s, setNode(parseViewRules(s), depot, include, clientNameOf(s) || name)));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</span>
|
||||
<div className="vt-modes" title={t("Switch between the folder tree and the raw spec")}>
|
||||
<button className={mode === "tree" ? "on" : ""} onClick={() => setMode("tree")} title={t("Folder tree (include / exclude)")}>{I.folder}</button>
|
||||
<button className={mode === "text" ? "on" : ""} onClick={() => setMode("text")} title={t("Raw spec text")}>{I.log}</button>
|
||||
</div>
|
||||
<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="tm-hint">{t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
|
||||
<div className="tm-hint">{mode === "tree"
|
||||
? t("Check a folder to include it in this workspace, uncheck to exclude. Excluded folders are not synced (not downloaded). This edits the depot → workspace View mapping.")
|
||||
: t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
|
||||
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
|
||||
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
|
||||
: mode === "tree"
|
||||
? <div className="vt-tree"><ViewTreeNode depot="//depot" name="depot" depth={0} rules={rules} onToggle={toggleNode} /></div>
|
||||
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
|
||||
|
||||
Reference in New Issue
Block a user