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:
Bonchellon
2026-07-08 23:26:46 +03:00
parent 1c1153eee9
commit 09b8f9d842
7 changed files with 152 additions and 8 deletions

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.3.3",
"version": "0.3.4",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@ -966,7 +966,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.3"
version = "0.3.4"
dependencies = [
"encoding_rs",
"serde",

View File

@ -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"

View File

@ -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",

View File

@ -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)}

View File

@ -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 (wont 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>

View File

@ -154,6 +154,15 @@ const D: Record<string, Tr> = {
"Minimize — the transfer keeps running in the background": { ru: "Свернуть — передача продолжится в фоне", de: "Minimieren — die Übertragung läuft im Hintergrund weiter", fr: "Réduire — le transfert continue en arrière-plan", es: "Minimizar — la transferencia continúa en segundo plano" },
"Submitting": { ru: "Отправка", de: "Übertrage", fr: "Envoi", es: "Enviando" },
"Getting latest": { ru: "Получение", de: "Aktualisiere", fr: "Récupération", es: "Obteniendo" },
"Included — click to exclude (wont be synced)": { ru: "Включено — клик, чтобы исключить (не будет качаться)", de: "Enthalten — klicken zum Ausschließen (wird nicht synchronisiert)", fr: "Inclus — cliquer pour exclure (ne sera pas synchronisé)", es: "Incluido — clic para excluir (no se sincronizará)" },
"Excluded — click to include (will be synced)": { ru: "Исключено — клик, чтобы включить (будет качаться)", de: "Ausgeschlossen — klicken zum Einschließen (wird synchronisiert)", fr: "Exclu — cliquer pour inclure (sera synchronisé)", es: "Excluido — clic para incluir (se sincronizará)" },
"(no subfolders)": { ru: "(нет подпапок)", de: "(keine Unterordner)", fr: "(aucun sous-dossier)", es: "(sin subcarpetas)" },
"Switch between the folder tree and the raw spec": { ru: "Переключить дерево папок / сырой спек", de: "Zwischen Ordnerbaum und Rohspezifikation wechseln", fr: "Basculer entre larborescence et la spec brute", es: "Alternar entre árbol de carpetas y spec en bruto" },
"Folder tree (include / exclude)": { ru: "Дерево папок (включить / исключить)", de: "Ordnerbaum (ein-/ausschließen)", fr: "Arborescence (inclure / exclure)", es: "Árbol de carpetas (incluir / excluir)" },
"Raw spec text": { ru: "Сырой текст спека", de: "Rohspezifikation", fr: "Spec brute", es: "Spec en bruto" },
"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.": { ru: "Отметь папку, чтобы включить её в воркспейс, сними галочку — исключить. Исключённые папки не синкаются (не качаются). Это правит маппинг View (депот → воркспейс).", de: "Ordner ankreuzen zum Einschließen, abwählen zum Ausschließen. Ausgeschlossene Ordner werden nicht synchronisiert. Bearbeitet das View-Mapping (Depot → Workspace).", fr: "Cochez un dossier pour linclure, décochez pour lexclure. Les dossiers exclus ne sont pas synchronisés. Modifie le mapping View (dépôt → workspace).", es: "Marca una carpeta para incluirla, desmárcala para excluirla. Las carpetas excluidas no se sincronizan. Edita el mapeo View (depósito → workspace)." },
"Workspace mapping changed": { ru: "Маппинг воркспейса изменён", de: "Workspace-Mapping geändert", fr: "Mapping du workspace modifié", es: "Mapeo del workspace cambiado" },
"Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync.": { ru: "Подтянуть сейчас под новый view? Только что включённые папки скачаются; исключённые удалятся с диска при следующем синке.", de: "Jetzt aktualisieren, um dem neuen View zu entsprechen? Neu eingeschlossene Ordner werden geladen; ausgeschlossene werden beim nächsten Sync von der Platte entfernt.", fr: "Récupérer maintenant selon le nouveau view ? Les dossiers nouvellement inclus sont téléchargés ; les exclus sont supprimés du disque au prochain sync.", es: "¿Obtener ahora según el nuevo view? Las carpetas recién incluidas se descargan; las excluidas se eliminan del disco en el próximo sync." },
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter lanalyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },