Initial commit — Exbyte Depot (native Perforce client)

Tauri 2 + React 19 + TypeScript. GitHub Desktop-style two-stage flow
(local Commit -> Submit), async p4 backend, three.js asset viewer,
resizable persisted panels, History revert (p4 undo), reveal-in-Explorer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 13:44:18 +03:00
commit f33ab7435b
42 changed files with 9855 additions and 0 deletions

159
src/p4.ts Normal file
View File

@ -0,0 +1,159 @@
// Typed wrappers around the Rust p4 commands.
import { invoke } from "@tauri-apps/api/core";
export interface P4Info {
serverAddress?: string;
serverVersion?: string;
userName?: string;
clientName?: string;
clientRoot?: string;
serverUptime?: string;
serverDate?: string;
[k: string]: unknown;
}
export interface OpenedFile {
depotFile?: string;
clientFile?: string;
rev?: string;
haveRev?: string;
action?: string; // edit | add | delete | branch | integrate | move/add | move/delete
type?: string; // text | binary | ...
change?: string; // "default" or a number
user?: string;
client?: string;
[k: string]: unknown;
}
export interface Client {
client?: string;
Root?: string;
Host?: string;
Stream?: string;
[k: string]: unknown;
}
export interface Change {
change?: string;
time?: string;
user?: string;
client?: string;
status?: string;
desc?: string;
[k: string]: unknown;
}
export const p4 = {
clients: (port: string, user: string) =>
invoke<Client[]>("p4_clients", { port, user }),
login: (port: string, user: string, password: string, client: string) =>
invoke<P4Info>("p4_login", { port, user, password, client }),
restore: (port: string, user: string, client: string) =>
invoke<P4Info>("p4_restore", { port, user, client }),
info: () => invoke<P4Info>("p4_info"),
opened: (scope = "") => invoke<OpenedFile[]>("p4_opened", { scope }),
dirs: (path = "") => invoke<Dir[]>("p4_dirs", { path }),
changes: () => invoke<Change[]>("p4_changes"),
history: (path: string) => invoke<Change[]>("p4_history", { path }),
disconnect: () => invoke<void>("p4_disconnect"),
sync: (scope = "") => invoke<string>("p4_sync", { scope }),
diff: (file: string) => invoke<string>("p4_diff", { file }),
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
submit: (description: string, files: string[]) =>
invoke<string>("p4_submit", { description, files }),
commit: (description: string, files: string[]) =>
invoke<string>("p4_commit", { description, files }),
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
add: (files: string[]) => invoke<OpenedFile[]>("p4_add", { files }),
edit: (files: string[]) => invoke<OpenedFile[]>("p4_edit", { files }),
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
readDepot: async (depot: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("read_depot", { depot });
if (r instanceof ArrayBuffer) return r;
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
};
export interface Dir { dir?: string; [k: string]: unknown }
// depot path -> short leaf name (last segment)
export function leaf(path?: string): string {
if (!path) return "";
const p = path.replace(/\/+$/, "");
return p.slice(p.lastIndexOf("/") + 1);
}
// a submitted changelist with indexed file fields (depotFile0, action0, ...)
export interface Describe extends Change { [k: string]: unknown }
export function describeFiles(d: Describe): { depotFile: string; action: string; rev: string }[] {
const out: { depotFile: string; action: string; rev: string }[] = [];
for (let i = 0; ; i++) {
const dp = d[`depotFile${i}`] as string | undefined;
if (!dp) break;
out.push({ depotFile: dp, action: (d[`action${i}`] as string) || "edit", rev: (d[`rev${i}`] as string) || "" });
}
return out;
}
// --- session persistence (server/user/workspace — никогда не пароль) ---
export interface Session { server: string; user: string; client: string }
const SESSION_KEY = "exd-session";
export function saveSession(s: Session) {
try { localStorage.setItem(SESSION_KEY, JSON.stringify(s)); } catch {}
}
export function loadSession(): Session | null {
try { const r = localStorage.getItem(SESSION_KEY); return r ? JSON.parse(r) : null; } catch { return null; }
}
export function clearSession() {
try { localStorage.removeItem(SESSION_KEY); } catch {}
}
// --- working-folder scope persistence (per workspace) ---
export function saveScope(client: string, path: string) {
try { localStorage.setItem("exd-scope-" + client, path); } catch {}
}
export function loadScope(client: string): string {
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
}
// format a unix-epoch string as a short local datetime
export function fmtTime(t?: string): string {
if (!t) return "";
const n = Number(t);
if (!n) return "";
const d = new Date(n * 1000);
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
// action -> single-char status + css class
export function statusOf(action?: string): { ch: string; cls: string } {
const a = (action || "").toLowerCase();
if (a.includes("add")) return { ch: "+", cls: "st-a" };
if (a.includes("delete")) return { ch: "", cls: "st-d" };
return { ch: "±", cls: "st-e" }; // edit / integrate / branch / move
}
// depot or client path -> { name, dir }
export function splitPath(p?: string): { name: string; dir: string } {
if (!p) return { name: "(unknown)", dir: "" };
const norm = p.replace(/\\/g, "/");
const i = norm.lastIndexOf("/");
return { name: norm.slice(i + 1), dir: norm.slice(0, i + 1) };
}
// classify by extension for the preview panel
export type Kind = "model" | "uasset" | "image" | "code";
export function kindOf(name: string): Kind {
const n = name.toLowerCase();
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
if (/\.(uasset|umap)$/.test(n)) return "uasset";
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
return "code";
}