Perforce ends the ticket on its own schedule, so a long day outlives a login. Every command then failed and the only way back was Exit + restart, which threw away the scope, selection and open panels. - p4.ts routes all ~110 commands through one invoke wrapper that recognises an auth failure (isAuthError) and raises onAuthLost, so no call site can miss it. p4_login / p4_restore / p4_clients are exempt: they report bad credentials inline and would otherwise loop the prompt. - The server poll no longer treats an expired ticket as an outage. The server answered, it just refused us — the offline banner it used to raise never cleared, since polling stayed refused forever. - ReauthModal asks for the password over the running app, keeping all state, and offers Sign out as the way back to the Connect screen. The failed command is NOT retried automatically — re-auth then refresh only. Replaying a half-finished submit or revert unattended is worse than asking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
401 lines
22 KiB
TypeScript
401 lines
22 KiB
TypeScript
// 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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||
return rawInvoke<T>(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<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);
|
||
}
|