// Typed wrappers around the Rust p4 commands. import { invoke as rawInvoke } from "@tauri-apps/api/core"; /** True when p4 rejected us for auth reasons — an expired or dropped ticket — * as opposed to the server being unreachable. Perforce expires tickets on its * own schedule (often 12h), so a workday can outlive a login. */ export function isAuthError(e: unknown): boolean { return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i .test(String(e)); } // Set by the app: called when any command comes back with an auth failure, so // the UI can ask for the password again instead of dead-ending on every action. let authLost: (() => void) | null = null; export function onAuthLost(fn: (() => void) | null) { authLost = fn; } // Commands that legitimately report bad credentials — the login screen shows // those inline, and firing the re-auth prompt from them would loop. const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]); /** Every p4 command funnels through here so a lost ticket is caught in one * place rather than at each of the ~110 call sites. */ function invoke(cmd: string, args?: Record): Promise { return rawInvoke(cmd, args).catch((e) => { if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.(); throw e; }); } 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 { 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("p4_clients", { port, user }), login: (port: string, user: string, password: string, client: string) => invoke("p4_login", { port, user, password, client }), restore: (port: string, user: string, client: string) => invoke("p4_restore", { port, user, client }), info: () => invoke("p4_info"), opened: (scope = "") => invoke("p4_opened", { scope }), dirs: (path = "") => invoke("p4_dirs", { path }), changes: () => invoke("p4_changes"), history: (path: string) => invoke("p4_history", { path }), disconnect: () => invoke("p4_disconnect"), sync: (scope = "") => invoke("p4_sync", { scope }), syncTo: (scope: string, rev: string) => invoke("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("p4_label_tag", { label, scope, rev }), branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke("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("p4_switch_stream", { stream }), streamInteg: (stream: string, direction: "down" | "up") => invoke("p4_stream_integ", { stream, direction }), filelog: (depot: string) => invoke>("p4_filelog", { depot }), cleanPreview: (scope = "") => invoke("p4_clean_preview", { scope }), cleanApply: (scope = "") => invoke("p4_clean_apply", { scope }), depotLs: (path = "") => invoke("depot_ls", { path }), changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }), fsLs: (path = "") => invoke("fs_ls", { path }), reconcilePreview: (scope = "") => invoke("p4_reconcile_preview", { scope }), reconcileApply: (paths: string[]) => invoke("p4_reconcile_apply", { paths }), setWritable: (paths: string[], writable: boolean) => invoke("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("p4_fix", { job, change }), jobSpec: (job: string) => invoke("p4_job_spec", { job }), jobSave: (spec: string) => invoke("p4_job_save", { spec }), diff: (file: string) => invoke("p4_diff", { file }), revert: (files: string[]) => invoke("p4_revert", { files }), submit: (description: string, files: string[]) => invoke("p4_submit", { description, files }), commit: (description: string, files: string[]) => invoke("p4_commit", { description, files }), submitChange: (change: string) => invoke("p4_submit_change", { change }), submitShelved: (change: string) => invoke("p4_submit_shelved", { change }), submitOpts: (change: string, reopen: boolean, revertUnchanged: boolean) => invoke("p4_submit_opts", { change, reopen, revertUnchanged }), ignoreRead: () => invoke("p4_ignore_read"), ignoreWrite: (content: string) => invoke("p4_ignore_write", { content }), reopenDefault: (files: string[]) => invoke("p4_reopen_default", { files }), setDesc: (change: string, description: string) => invoke("p4_set_desc", { change, description }), add: (files: string[]) => invoke("p4_add", { files }), edit: (files: string[]) => invoke("p4_edit", { files }), del: (files: string[]) => invoke("p4_delete", { files }), reconcile: (path: string) => invoke("p4_reconcile", { path }), scan: (scope = "") => invoke("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("p4_scan_stream", { scope }), scanCancel: () => invoke("p4_scan_cancel"), // ask an in-progress submit/sync transfer to stop (safe pre-commit for submit) transferCancel: () => invoke("p4_transfer_cancel"), // --- plugins --- pluginList: () => invoke("plugin_list"), pluginReadEntry: (id: string) => invoke("plugin_read_entry", { id }), pluginSetEnabled: (id: string, enabled: boolean) => invoke("plugin_set_enabled", { id, enabled }), pluginWriteFile: (id: string, name: string, content: string) => invoke("plugin_write_file", { id, name, content }), registryHttpGet: (url: string) => invoke("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("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("p4_protects_max"), pluginInstall: (src: string) => invoke("plugin_install", { src }), pluginRemove: (id: string) => invoke("plugin_remove", { id }), pluginsDirPath: () => invoke("plugins_dir_path"), openPluginsDir: () => invoke("open_plugins_dir"), describe: (change: string) => invoke("p4_describe", { change }), undoChange: (change: string) => invoke("p4_undo_change", { change }), openInExplorer: (target: string) => invoke("open_in_explorer", { target }), findUproject: (scope: string) => invoke("find_uproject", { scope }), findSln: (scope: string) => invoke("find_sln", { scope }), buildSln: (path: string, config: string, uproject = "") => invoke("build_sln", { path, config, uproject }), launchFile: (path: string) => invoke("launch_file", { path }), listEditors: () => invoke("list_editors"), openInEditor: (depot: string, editor: string) => invoke("open_in_editor", { depot, editor }), readDepot: async (depot: string): Promise => { 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 => { 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("write_depot", { depot, content }), switchClient: (client: string) => invoke("p4_switch_client", { client }), clientSpec: (client: string) => invoke("p4_client_spec", { client }), clientSave: (spec: string) => invoke("p4_client_save", { spec }), users: () => invoke("p4_users"), groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }), groupMember: (group: string, user: string, add: boolean) => invoke("p4_group_member", { group, user, add }), readAiKey: () => invoke("read_ai_key"), saveAiKey: (key: string) => invoke("save_ai_key", { key }), // run an arbitrary p4 command in the built-in terminal (combined stdout+stderr) console: (args: string[]) => invoke("p4_console", { args }), // tail the newest Unreal log for a project (.uproject path → Saved/Logs/*.log) ueLog: (uproject: string, tailBytes = 200_000) => invoke("ue_log", { uproject, tailBytes }), // --- collaboration / advanced Perforce --- openedAll: (scope = "") => invoke("p4_opened_all", { scope }), lock: (files: string[]) => invoke("p4_lock", { files }), unlock: (files: string[]) => invoke("p4_unlock", { files }), typemapGet: () => invoke("p4_typemap_get"), typemapSet: (entries: string[]) => invoke("p4_typemap_set", { entries }), reopenType: (files: string[], filetype: string) => invoke("p4_reopen_type", { files, filetype }), shelve: (change: string) => invoke("p4_shelve", { change }), unshelve: (change: string) => invoke("p4_unshelve", { change }), deleteShelf: (change: string) => invoke("p4_delete_shelf", { change }), shelvedFiles: (change: string) => invoke("p4_shelved_files", { change }), resolveList: () => invoke("p4_resolve_list"), resolveFile: (file: string, mode: "am" | "ay" | "at") => invoke("p4_resolve_file", { file, mode }), diff2: (spec1: string, spec2: string) => invoke("p4_diff2", { spec1, spec2 }), annotate: (spec: string) => invoke("p4_annotate", { spec }), searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }), reopenTo: (change: string, files: string[]) => invoke("p4_reopen_to", { change, files }), latestChange: (scope = "") => invoke("p4_latest_change", { scope }), behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }), makeFolder: (scope: string, name: string) => invoke("make_folder", { scope, name }), deleteFolder: (path: string) => invoke("delete_folder", { path }), renameFolder: (path: string, newName: string) => invoke("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 => { 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 => { 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; 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): 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); }