Files
exbyte-depot-viewer-perforce/src/p4.ts
Bonchellon ecc5b8e9bf File history timeline (filelog) with diff-to-previous
- File history button in the preview header opens a per-file revision timeline
  (rev, changelist, action, user, date, description) with Diff to previous
- Backend p4 filelog -l; filelogRevs parser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:03:32 +03:00

280 lines
14 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 }),
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 }),
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 }),
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) => invoke<string>("build_sln", { path, config }),
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 }),
// 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; [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);
}
// 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 }
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 ""; }
}
// 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";
}
// 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);
}