Files
exbyte-depot-viewer-perforce/src/p4.ts
Bonchellon 37f5c504f7 Fix dead Unreal plugin actions, sync toast flood, Team Relay theming
- ue bridge: Launch / Build now read live refs. They captured startBuild /
  launchUE from the render where plugins loaded — before the async .uproject
  and .sln detection had resolved — so both saw empty paths forever. hasProject
  used the live ref, so the menu items showed up and then did nothing.
- Get Latest / Sync to revision: summarise `p4 sync` stdout (syncSummary)
  instead of dumping every depot path into the toast. flash() caps length and
  .toast caps height so no raw output can blow up the bubble again.
- Team Relay: use the real theme tokens — --txt / --add / --del, not
  --text / --ok / --err, which don't exist and silently fell back to a
  near-white hardcode, invisible on the light theme. Bumped to 1.0.1.
- Plugin dock panels get a status-bar button next to Terminal / Log; the tabs
  were otherwise only reachable from the Actions menu.
- PLUGINS.md documents the theme token names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:50:14 +03:00

375 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
_spec?: string; // when set, read this exact rev via `p4 print` (submitted/historical file)
[k: string]: unknown;
}
// choose the byte source: an exact submitted revision (`p4 print`) when `_spec`
// is set, otherwise the working copy (local file via `p4 where`).
export function fileBytes(f: OpenedFile): Promise<ArrayBuffer> {
return f._spec ? p4.printDepot(f._spec) : p4.readDepot(f.depotFile || "");
}
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 }),
syncTo: (scope: string, rev: string) => invoke<string>("p4_sync_to", { scope, rev }),
labels: () => invoke<{ label?: string; Update?: string; Owner?: string; Description?: string; [k: string]: unknown }[]>("p4_labels"),
labelTag: (label: string, scope: string, rev = "") => invoke<string>("p4_label_tag", { label, scope, rev }),
branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke<string>("p4_branch_op", { op, source, target }),
streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"),
switchStream: (stream: string) => invoke<P4Info>("p4_switch_stream", { stream }),
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }),
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
setWritable: (paths: string[], writable: boolean) => invoke<string>("p4_set_writable", { paths, writable }),
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
jobSave: (spec: string) => invoke<string>("p4_job_save", { spec }),
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 }),
submitShelved: (change: string) => invoke<string>("p4_submit_shelved", { change }),
submitOpts: (change: string, reopen: boolean, revertUnchanged: boolean) => invoke<string>("p4_submit_opts", { change, reopen, revertUnchanged }),
ignoreRead: () => invoke<string>("p4_ignore_read"),
ignoreWrite: (content: string) => invoke<void>("p4_ignore_write", { content }),
reopenDefault: (files: string[]) => invoke<string>("p4_reopen_default", { files }),
setDesc: (change: string, description: string) => invoke<string>("p4_set_desc", { change, description }),
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 }),
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
// The caller re-reads opened() for the actual list.
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
scanCancel: () => invoke<void>("p4_scan_cancel"),
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
transferCancel: () => invoke<void>("p4_transfer_cancel"),
// --- plugins ---
pluginList: () => invoke<PluginInfo[]>("plugin_list"),
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
// generic HTTP pipe for relay-backed plugins (any http(s) URL, optional bearer)
relayHttp: (url: string, method: string, body?: string, token?: string) =>
invoke<string>("relay_http", { url, method, body, token }),
// shelve everything open in the default changelist → { change, files }
shelveOpen: (description: string) =>
invoke<{ change: string; files: number }>("p4_shelve_open", { description }),
// caller's max protection level on the server (super/admin/write/…)
protectsMax: () => invoke<string>("p4_protects_max"),
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
openPluginsDir: () => invoke<void>("open_plugins_dir"),
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 }),
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
launchFile: (path: string) => invoke<void>("launch_file", { path }),
listEditors: () => invoke<Editor[]>("list_editors"),
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
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;
},
// read raw bytes at an exact revision (submitted/historical), e.g. "//depot/f.cpp#7"
printDepot: async (spec: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("print_depot", { spec });
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;
},
// built-in editor: write text back to a depot file's working copy
writeDepot: (depot: string, content: string) => invoke<void>("write_depot", { depot, content }),
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
clientSpec: (client: string) => invoke<string>("p4_client_spec", { client }),
clientSave: (spec: string) => invoke<string>("p4_client_save", { spec }),
users: () => invoke<User[]>("p4_users"),
groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }),
groupMember: (group: string, user: string, add: boolean) => invoke<string>("p4_group_member", { group, user, add }),
readAiKey: () => invoke<string>("read_ai_key"),
saveAiKey: (key: string) => invoke<void>("save_ai_key", { key }),
// run an arbitrary p4 command in the built-in terminal (combined stdout+stderr)
console: (args: string[]) => invoke<string>("p4_console", { args }),
// tail the newest Unreal log for a project (.uproject path → Saved/Logs/*.log)
ueLog: (uproject: string, tailBytes = 200_000) => invoke<string>("ue_log", { uproject, tailBytes }),
// --- collaboration / advanced Perforce ---
openedAll: (scope = "") => invoke<OpenedFile[]>("p4_opened_all", { scope }),
lock: (files: string[]) => invoke<string>("p4_lock", { files }),
unlock: (files: string[]) => invoke<string>("p4_unlock", { files }),
typemapGet: () => invoke<string[]>("p4_typemap_get"),
typemapSet: (entries: string[]) => invoke<string>("p4_typemap_set", { entries }),
reopenType: (files: string[], filetype: string) => invoke<string>("p4_reopen_type", { files, filetype }),
shelve: (change: string) => invoke<string>("p4_shelve", { change }),
unshelve: (change: string) => invoke<string>("p4_unshelve", { change }),
deleteShelf: (change: string) => invoke<string>("p4_delete_shelf", { change }),
shelvedFiles: (change: string) => invoke<Describe>("p4_shelved_files", { change }),
resolveList: () => invoke<OpenedFile[]>("p4_resolve_list"),
resolveFile: (file: string, mode: "am" | "ay" | "at") => invoke<string>("p4_resolve_file", { file, mode }),
diff2: (spec1: string, spec2: string) => invoke<string>("p4_diff2", { spec1, spec2 }),
annotate: (spec: string) => invoke<string>("p4_annotate", { spec }),
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
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;
},
// export a working-copy .uasset mesh to FBX via headless Unreal → real 3D
exportUasset3d: async (depot: string, uproject: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_export_3d", { depot, uproject });
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;
},
};
export interface User { User?: string; Email?: string; FullName?: string; Access?: string; Update?: string; [k: string]: unknown }
// a detected code editor / IDE
export interface Editor { id: string; name: string; exe: string; args: string[] }
// which editor to open code files in (persisted; "" → auto-pick)
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
export interface PluginInfo { id: string; name: string; version: string; description: string; author: string; permissions: string[]; manifest: Record<string, unknown>; enabled: boolean; dir: string }
// 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);
}
// per-file revision history parsed from p4 filelog's indexed fields
export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string }
/** One entry (file or folder) in the Depot/Workspace size explorer. `size` is bytes. */
export interface FsEntry { name: string; path: string; isDir: boolean; size: number; fileCount: number }
/** Human-readable byte size, e.g. 1536 → "1.5 KB". */
export function humanSize(n: number): string {
if (!n) return "0 B";
if (n < 1024) return `${n} B`;
const u = ["KB", "MB", "GB", "TB", "PB"];
let v = n, i = -1;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
}
export function filelogRevs(v: Record<string, unknown>): FileRev[] {
const out: FileRev[] = [];
for (let i = 0; ; i++) {
const rev = v[`rev${i}`] as string | undefined;
if (rev == null) break;
out.push({
rev,
change: (v[`change${i}`] as string) || "",
action: (v[`action${i}`] as string) || "",
user: (v[`user${i}`] as string) || "",
time: (v[`time${i}`] as string) || "",
desc: (v[`desc${i}`] as string) || "",
type: (v[`type${i}`] as string) || "",
});
}
return out;
}
// 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 ""; }
}
// last 3 chosen working folders (Recently) — most-recent first, per workspace
export function recentScopes(client: string): string[] {
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
}
export function pushRecentScope(client: string, path: string) {
try {
const cur = recentScopes(client).filter((p) => p !== path);
const next = [path, ...cur].slice(0, 3);
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
} catch {}
}
// 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" });
}
/** Condense `p4 sync` stdout into one short line.
*
* A sync prints one line per file — `//depot/F.uasset#3 - updating d:\ws\F.uasset` —
* so a real Get Latest returns thousands of full paths. Dumping that into a toast
* buries the screen; the per-file detail already streamed into the transfer dialog.
* Short outputs ("File(s) up-to-date.") pass through unchanged. */
export function syncSummary(out: string): string {
const text = (out || "").trim();
if (!text) return "up to date";
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
// p4 says its piece in one line when nothing moved ("File(s) up-to-date.") and
// so do server errors — keep those verbatim; flash() caps the length.
if (lines.length === 1) return lines[0];
let added = 0, updated = 0, deleted = 0;
for (const l of lines) {
if (/ - (added as|refreshing) /.test(l)) added++;
else if (/ - (updating|replacing) /.test(l)) updated++;
else if (/ - deleted as /.test(l)) deleted++;
}
const moved = added + updated + deleted;
// unrecognised shape (server notices, errors) — report the line count, not the text
if (!moved) return `${lines.length.toLocaleString()} lines`;
const parts: string[] = [];
if (added) parts.push(`+${added.toLocaleString()}`);
if (updated) parts.push(`~${updated.toLocaleString()}`);
if (deleted) parts.push(`${deleted.toLocaleString()}`);
return `${moved.toLocaleString()} files (${parts.join(" ")})`;
}
// 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" | "audio" | "video" | "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";
if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/.test(n)) return "audio";
if (/\.(mp4|webm|mov|m4v|ogv)$/.test(n)) return "video";
return "code";
}
// true only for real text/source files — gates the "Summary" and "Open in IDE"
// buttons so they never appear on binaries (.pak/.dll/.exe/.bin/etc.) that
// happen to fall through kindOf() as "code".
const CODE_EXT = /\.(c|cc|cpp|cxx|h|hpp|hh|inl|cs|ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|kts|swift|m|mm|rb|php|lua|sh|bash|ps1|bat|cmd|sql|json|jsonc|yaml|yml|toml|ini|cfg|conf|xml|html|htm|css|scss|sass|less|md|markdown|txt|log|csv|gitignore|editorconfig|uproject|uplugin|build|target|usf|ush|hlsl|glsl|shader|cginc|props|targets|sln|vcxproj|csproj|gradle|cmake|make|mk)$/i;
export function isCodeFile(name: string): boolean {
const n = name.toLowerCase();
if (/\.(uasset|umap|pak|exe|dll|so|dylib|bin|obj|lib|a|pdb|zip|7z|rar|gz|tar|png|jpe?g|gif|bmp|webp|svg|fbx|obj|gltf|glb|stl|ply|dae|wav|mp3|ogg|mp4|mov|ttf|otf)$/i.test(n)) return false;
return CODE_EXT.test(n);
}