From 09b8f9d8426e9a72063b1ca5c04a16c62f2cf728 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 23:26:46 +0300 Subject: [PATCH] =?UTF-8?q?v0.3.4=20=E2=80=94=20workspace=20View=20include?= =?UTF-8?q?/exclude=20tree=20+=20fix=20oversized=20button=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 , 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 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.css | 24 +++++++- src/App.tsx | 119 +++++++++++++++++++++++++++++++++++++- src/i18n.ts | 9 +++ 7 files changed, 152 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 618d833..0da63e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.3", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 96bf3e6..6a6fcfb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.3.3" +version = "0.3.4" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dd9538f..a1c4f77 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.3.3" +version = "0.3.4" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e14d296..b445f48 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.3.3", + "version": "0.3.4", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 6115240..5a3b186 100644 --- a/src/App.css +++ b/src/App.css @@ -653,6 +653,27 @@ body.resizing-v{cursor:row-resize!important;user-select:none} background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto; box-shadow:inset 3px 0 0 var(--border-soft)} .code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)} +/* workspace View include/exclude tree (P4V-style) */ +.vt-modes{margin-left:auto;display:flex;gap:2px;background:var(--panel-3);border:1px solid var(--border);border-radius:9px;padding:2px} +.vt-modes button{width:28px;height:24px;display:grid;place-items:center;border:none;background:none;color:var(--faint);border-radius:7px;cursor:pointer;transition:.15s} +.vt-modes button svg{width:15px;height:15px} +.vt-modes button:hover{color:var(--txt)} +.vt-modes button.on{background:var(--elevated);color:var(--accent-2)} +.vt-tree{flex:1;min-height:0;overflow:auto;background:var(--elevated);padding:6px 4px} +.vt-node{user-select:none} +.vt-row{display:flex;align-items:center;gap:7px;height:26px;border-radius:7px;padding-right:8px;cursor:default} +.vt-row:hover{background:#ffffff08} +.vt-chev{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--faint);cursor:pointer} +.vt-chev svg{width:13px;height:13px;transition:transform .12s ease} +.vt-chk{width:16px;height:16px;flex:0 0 auto;border-radius:4px;border:1.5px solid var(--border);display:grid;place-items:center;cursor:pointer;transition:.12s;background:var(--panel)} +.vt-chk:hover{border-color:var(--accent)} +.vt-chk.on{background:var(--accent);border-color:var(--accent)} +.vt-chk.mixed{background:var(--faint);border-color:var(--faint)} +.vt-chk svg{width:12px;height:12px} +.vt-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)} +.vt-ic svg{width:15px;height:15px} +.vt-name{font-size:13px;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.vt-loading,.vt-empty{height:24px;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint)} /* rich code editor: transparent textarea over a highlighted underlay + gutter */ .code.editing{overflow:hidden} .code.editing .code-gutter{overflow:hidden} @@ -799,7 +820,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .modal-card h3{font-size:17px;font-weight:700;margin-bottom:8px} .modal-card p{font-size:13px;line-height:1.55;color:var(--muted);white-space:pre-wrap;margin-bottom:22px} .modal-actions{display:flex;gap:10px} -.mbtn{flex:1;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s} +.mbtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s} +.mbtn svg{width:15px;height:15px;flex:0 0 auto} .mbtn.ghost{background:var(--panel);border:1px solid var(--border);color:var(--txt)} .mbtn.ghost:hover{background:var(--hover)} .mbtn.primary{color:#fff;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7)} diff --git a/src/App.tsx b/src/App.tsx index 90e7b0d..941d662 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1682,7 +1682,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "folderprops" && 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" && setModal(null)} onFlash={flash} />} {modal?.kind === "filelog" && setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />} - {modal?.kind === "client" && setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />} + {modal?.kind === "client" && 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" && setModal(null)} onFlash={flash} />} {modal?.kind === "diff" && setModal(null)} />} {resolveOpen && 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 ( +
+
+ setOpen((o) => !o)}> + + + 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 ? + : st.included ? : null} + + {I.folder} + {name} +
+ {open && kids && kids.map((k) => )} + {open && loading &&
} + {open && kids && kids.length === 0 && !loading &&
{t("(no subfolders)")}
} +
+ ); +} + 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 (
e.stopPropagation()}>
{I.monitor}

{isNew ? t("New workspace") : t("Edit workspace")}

{name} +
+ + +
-
{t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}
+
{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.")}
{busy ?
{t("Loading…")}
- :