1
0

Compare commits

...

3 Commits

Author SHA1 Message Date
8b08d9571e Scale 2026-05-19 16:31:29 +10:00
bdf5f4b44e Aiden added closer matching look 2026-05-19 16:23:51 +10:00
676290faf7 refactor 2026-05-19 16:19:27 +10:00
15 changed files with 1988 additions and 1196 deletions

View File

@@ -36,6 +36,10 @@ PUT /remote/object/property
The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unreal for all readable properties exposed on that UObject.
Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background.
Asset-looking property values also request `/remote/object/thumbnail` and cache thumbnail data in `localStorage`, so mesh/material fields can render closer to Unreal's Details panel.
## Run
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.

283
src/App.tsx Normal file
View File

@@ -0,0 +1,283 @@
import React from "react";
import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshCw, Search, Settings, SlidersHorizontal, X } from "lucide-react";
import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types";
import { DetailsPanel } from "./components/DetailsPanel";
import { OutlinerRow } from "./components/OutlinerRow";
import { extractObjectPath, extractReturnValue } from "./lib/remoteValues";
import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree";
import { normalizeDetails } from "./services/details";
import { readCachedDetails, writeCachedDetails } from "./services/detailsCache";
import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl";
import { enrichActors, getAllLevelActors, normalizeActor } from "./services/unrealActors";
const SPLIT_STORAGE_KEY = "unreal-outliner.split-ratio";
const DEFAULT_SPLIT_RATIO = 0.52;
const MIN_SPLIT_RATIO = 0.22;
const MAX_SPLIT_RATIO = 0.78;
function readSavedSplitRatio(): number {
const raw = window.localStorage.getItem(SPLIT_STORAGE_KEY);
const value = raw ? Number(raw) : DEFAULT_SPLIT_RATIO;
return Number.isFinite(value) ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, value)) : DEFAULT_SPLIT_RATIO;
}
export function App() {
const [actors, setActors] = React.useState<ActorRow[]>([]);
const [query, setQuery] = React.useState("");
const [expanded, setExpanded] = React.useState<Set<string>>(new Set(["world"]));
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [status, setStatus] = React.useState("Ready");
const [transport, setTransport] = React.useState<RemoteTransport>("HTTP");
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [details, setDetails] = React.useState<ObjectDetails | null>(null);
const [detailsError, setDetailsError] = React.useState<string | null>(null);
const [isDetailsLoading, setIsDetailsLoading] = React.useState(false);
const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio);
const mainContentRef = React.useRef<HTMLDivElement | null>(null);
const tree = React.useMemo(() => buildTree(actors), [actors]);
const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]);
const refresh = React.useCallback(async () => {
setIsLoading(true);
setError(null);
setStatus("Loading actors...");
try {
const payload = await getAllLevelActors();
const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) }));
const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index)));
setTransport(getActiveTransport());
setActors(rows);
setExpanded((current) => new Set([...current, "world"]));
setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`);
} catch (reason) {
const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control";
setError(message);
setStatus("Connection failed");
} finally {
setIsLoading(false);
}
}, []);
React.useEffect(() => {
refresh();
}, [refresh]);
React.useEffect(() => {
let isCancelled = false;
if (!selectedNode?.objectPath) {
setDetails(null);
setDetailsError(null);
setIsDetailsLoading(false);
return;
}
const objectPath = selectedNode.objectPath;
const cachedDetails = readCachedDetails(objectPath);
if (cachedDetails) {
setDetails(cachedDetails);
setIsDetailsLoading(false);
} else {
setDetails(null);
setIsDetailsLoading(true);
}
setDetailsError(null);
Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)])
.then(([description, properties]) => {
if (!isCancelled) {
const freshDetails = normalizeDetails(description, properties);
writeCachedDetails(objectPath, freshDetails);
setDetails(freshDetails);
setTransport(getActiveTransport());
}
})
.catch((reason) => {
if (!isCancelled) {
setDetails(null);
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
}
})
.finally(() => {
if (!isCancelled) {
setIsDetailsLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [selectedNode?.objectPath]);
const toggleNode = (node: TreeNode) => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(node.id)) {
next.delete(node.id);
} else {
next.add(node.id);
}
return next;
});
};
const setClampedSplitRatio = React.useCallback((nextRatio: number) => {
const clamped = Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, nextRatio));
setSplitRatio(clamped);
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(clamped));
}, []);
const startResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const container = mainContentRef.current;
if (!container) {
return;
}
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const onPointerMove = (moveEvent: PointerEvent) => {
const bounds = container.getBoundingClientRect();
const nextRatio = (moveEvent.clientY - bounds.top) / bounds.height;
setClampedSplitRatio(nextRatio);
};
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
};
const resizeWithKeyboard = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault();
setClampedSplitRatio(splitRatio - 0.04);
}
if (event.key === "ArrowDown") {
event.preventDefault();
setClampedSplitRatio(splitRatio + 0.04);
}
if (event.key === "Home") {
event.preventDefault();
setClampedSplitRatio(MIN_SPLIT_RATIO);
}
if (event.key === "End") {
event.preventDefault();
setClampedSplitRatio(MAX_SPLIT_RATIO);
}
};
return (
<main className="shell">
<section className="panel" aria-label="Unreal Outliner">
<header className="tabs">
<div className="tab active">
<SlidersHorizontal size={24} />
<span>Outliner</span>
<X size={18} />
</div>
<div className="tab">
<Mountain size={23} />
<span>Levels</span>
</div>
<div className="tab">
<Layers size={23} />
<span>Layers</span>
</div>
</header>
<div className="toolbar">
<button className="icon-button" type="button" title="View options">
<SlidersHorizontal size={22} />
</button>
<button className="icon-button" type="button" title="Collapse or expand root" onClick={() => toggleNode(tree)}>
{expanded.has("world") ? <ChevronDown size={21} /> : <ChevronRight size={21} />}
</button>
<label className="search">
<Search size={22} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search..." />
</label>
<button className="icon-button" type="button" title="Refresh actors" onClick={refresh} disabled={isLoading}>
<RefreshCw size={21} className={isLoading ? "spin" : ""} />
</button>
<button className="icon-button" type="button" title="Create folder">
<PackagePlus size={21} />
</button>
<button className="icon-button" type="button" title="Settings">
<Settings size={22} />
</button>
</div>
<div className="columns" role="row">
<span className="visibility">
<Eye size={20} />
</span>
<span>Item Label</span>
<span>Type</span>
<span>Level</span>
<span>ID Name</span>
</div>
<div
className="main-content"
ref={mainContentRef}
style={{ "--outliner-ratio": splitRatio, "--details-ratio": 1 - splitRatio } as React.CSSProperties}
>
<div className="rows" role="tree">
{error ? (
<div className="empty">
<strong>{status}</strong>
<span>{error}</span>
<span>Remote Control is reachable, but Unreal rejected the object call. Check the exact error above.</span>
</div>
) : (
<OutlinerRow
node={tree}
depth={0}
expanded={expanded}
selectedId={selectedId}
query={query}
onToggle={toggleNode}
onSelect={(node) => setSelectedId(node.id)}
/>
)}
</div>
<div
aria-label="Resize Outliner and Details panes"
aria-orientation="horizontal"
aria-valuemax={Math.round(MAX_SPLIT_RATIO * 100)}
aria-valuemin={Math.round(MIN_SPLIT_RATIO * 100)}
aria-valuenow={Math.round(splitRatio * 100)}
className="pane-splitter"
onDoubleClick={() => setClampedSplitRatio(DEFAULT_SPLIT_RATIO)}
onKeyDown={resizeWithKeyboard}
onPointerDown={startResize}
role="separator"
tabIndex={0}
title="Drag to resize panes. Double-click to reset."
>
<span />
</div>
<DetailsPanel node={selectedNode} details={details} isLoading={isDetailsLoading} error={detailsError} />
</div>
<footer className="footer">
<span>{query ? `${visibleCount(tree)} actors total` : `${actors.length} actor${actors.length === 1 ? "" : "s"}`}</span>
<span>
{status} - {transport}
</span>
</footer>
</section>
</main>
);
}

View File

@@ -0,0 +1,229 @@
import React from "react";
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
import type { DetailProperty, ObjectDetails, TreeNode } from "../types";
import { basename } from "../lib/remoteValues";
import {
detailAssetPath,
formatDetailValue,
isBooleanProperty,
isVectorLikeProperty,
normalizeThumbnailPayload,
vectorParts,
} from "../services/details";
import { readRemoteObjectThumbnail } from "../services/remoteControl";
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
import { NodeIcon } from "./NodeIcon";
const emptyNode: TreeNode = {
id: "empty",
label: "No Selection",
type: "Object",
level: "",
idName: "",
kind: "actor",
children: [],
};
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"];
function DetailValue({ property }: { property: DetailProperty }) {
const assetPath = detailAssetPath(property);
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
React.useEffect(() => {
let isCancelled = false;
if (!assetPath) {
setThumbnail("");
return;
}
const cached = readCachedThumbnail(assetPath);
if (cached) {
setThumbnail(cached);
return;
}
readRemoteObjectThumbnail(assetPath)
.then((payload) => {
const dataUrl = normalizeThumbnailPayload(payload);
if (!isCancelled && dataUrl) {
setThumbnail(dataUrl);
writeCachedThumbnail(assetPath, dataUrl);
}
})
.catch(() => {
if (!isCancelled) {
setThumbnail("");
}
});
return () => {
isCancelled = true;
};
}, [assetPath]);
if (assetPath) {
return (
<div className="asset-value">
<div className="asset-thumb">{thumbnail ? <img src={thumbnail} alt="" /> : <Box size={28} />}</div>
<div className="asset-control">
<span>{formatDetailValue(property.value)}</span>
<ChevronDown size={16} />
</div>
</div>
);
}
if (isVectorLikeProperty(property)) {
return (
<div className="vector-value">
{vectorParts(property.value).map((part, index) => (
<span className={`axis axis-${index}`} key={part.label}>
{part.value}
</span>
))}
</div>
);
}
if (isBooleanProperty(property)) {
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />;
}
return (
<div className="text-value">
<span>{formatDetailValue(property.value)}</span>
</div>
);
}
export function DetailsPanel({
node,
details,
isLoading,
error,
}: {
node: TreeNode | null;
details: ObjectDetails | null;
isLoading: boolean;
error: string | null;
}) {
const [query, setQuery] = React.useState("");
const [activeCategory, setActiveCategory] = React.useState("All");
const groupedProperties = React.useMemo(() => {
const lowerQuery = query.toLowerCase();
const visibleProperties = (details?.properties ?? []).filter((property) => {
const matchesCategory = activeCategory === "All" || property.category.toLowerCase().includes(activeCategory.toLowerCase());
if (!matchesCategory) return false;
if (!lowerQuery) return true;
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
field.toLowerCase().includes(lowerQuery),
);
});
return visibleProperties.reduce<Record<string, DetailProperty[]>>((groups, property) => {
const category = property.category || "General";
groups[category] = groups[category] ?? [];
groups[category].push(property);
return groups;
}, {});
}, [activeCategory, details, query]);
return (
<aside className="details-panel" aria-label="Details">
<header className="details-tabs">
<div className="details-tab muted">
<Settings size={18} />
<span>World Settings</span>
</div>
<div className="details-tab active">
<SlidersHorizontal size={18} />
<span>Details</span>
<X size={15} />
</div>
</header>
<div className="details-object">
<NodeIcon node={node ?? emptyNode} />
<div>
<strong>{node?.label ?? "No Selection"}</strong>
<span>{details?.className ? basename(details.className) : node?.type ?? "Select an actor in the Outliner"}</span>
</div>
</div>
<div className="component-list">
<div className="component-row selected">
<NodeIcon node={node ?? emptyNode} />
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
</div>
<div className="component-row child">
<Box size={18} />
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span>
</div>
</div>
<div className="details-search-row">
<label className="details-search">
<Search size={18} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" />
</label>
<button className="details-tool" type="button" title="View Options">
<Grid2X2 size={18} />
</button>
<button className="details-tool" type="button" title="Favorites">
<Star size={18} />
</button>
<button className="details-tool" type="button" title="Settings">
<Settings size={18} />
</button>
</div>
<div className="category-tabs">
{categoryTabs.map((category) => (
<button
className={activeCategory === category ? "active" : ""}
key={category}
type="button"
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
<div className="details-categories">
{isLoading ? (
<div className="details-empty">Loading properties...</div>
) : error ? (
<div className="details-empty">{error}</div>
) : !node?.objectPath ? (
<div className="details-empty">Select an actor to inspect exposed properties.</div>
) : Object.keys(groupedProperties).length === 0 ? (
<div className="details-empty">No readable properties found.</div>
) : (
Object.entries(groupedProperties).map(([category, properties]) => (
<section className="detail-category" key={category}>
<h3>
<ChevronDown size={16} />
{category}
</h3>
{properties.map((property) => (
<div className="detail-row" key={property.name} title={property.description || property.type}>
<span className="detail-name">{property.displayName}</span>
<span className="detail-value">
<DetailValue property={property} />
<button className="reset-button row-reset" type="button" title="Reset to default">
<RotateCcw size={15} />
</button>
</span>
</div>
))}
</section>
))
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, Cloud, Folder, Gamepad2, Mountain, Sun, Triangle } from "lucide-react";
import type { TreeNode } from "../types";
function actorIcon(type: string) {
const lower = type.toLowerCase();
if (lower.includes("directional") || lower.includes("light")) return Sun;
if (lower.includes("fog")) return Cloud;
if (lower.includes("sky") || lower.includes("atmosphere")) return Mountain;
if (lower.includes("start") || lower.includes("pawn")) return Gamepad2;
if (lower.includes("mesh")) return Box;
return Triangle;
}
export function NodeIcon({ node }: { node: TreeNode }) {
if (node.kind === "folder") {
return <Folder className="folder-icon" size={20} />;
}
if (node.kind === "world") {
return <Mountain className="world-icon" size={19} />;
}
const Icon = actorIcon(node.type);
return <Icon className="actor-icon" size={19} />;
}

View File

@@ -0,0 +1,77 @@
import type React from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { TreeNode } from "../types";
import { nodeMatchesQuery } from "../models/outlinerTree";
import { NodeIcon } from "./NodeIcon";
export function OutlinerRow({
node,
depth,
expanded,
selectedId,
query,
onToggle,
onSelect,
}: {
node: TreeNode;
depth: number;
expanded: Set<string>;
selectedId: string | null;
query: string;
onToggle: (node: TreeNode) => void;
onSelect: (node: TreeNode) => void;
}) {
const hasChildren = node.children.length > 0;
const isOpen = expanded.has(node.id);
const matches =
!query ||
[node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(query.toLowerCase()));
const visibleChildren = node.children.filter((child) => nodeMatchesQuery(child, query));
if (!matches && visibleChildren.length === 0) {
return null;
}
return (
<>
<button
className={`outliner-row ${selectedId === node.id ? "selected" : ""}`}
style={{ "--depth": depth } as React.CSSProperties}
onClick={() => onSelect(node)}
onDoubleClick={() => hasChildren && onToggle(node)}
title={node.objectPath || node.label}
>
<span className="cell item-cell">
<span className="indent" />
<span
className="expander"
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(node);
}}
>
{hasChildren ? isOpen ? <ChevronDown size={17} /> : <ChevronRight size={17} /> : null}
</span>
<NodeIcon node={node} />
<span className="label">{node.label}</span>
</span>
<span className="cell type-cell">{node.type}</span>
<span className="cell level-cell">{node.level}</span>
<span className="cell id-cell">{node.idName}</span>
</button>
{(isOpen || query) &&
visibleChildren.map((child) => (
<OutlinerRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
selectedId={selectedId}
query={query}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</>
);
}

151
src/lib/remoteValues.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { RemoteObject } from "../types";
export function asRecord(value: unknown): RemoteObject {
return value && typeof value === "object" && !Array.isArray(value) ? (value as RemoteObject) : {};
}
export function isObjectRecord(value: unknown): value is RemoteObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function firstString(source: RemoteObject, keys: string[], fallback = ""): string {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) {
return value;
}
if (typeof value === "number") {
return String(value);
}
}
return fallback;
}
export function findStringDeep(value: unknown, keys: string[], depth = 0): string {
if (depth > 3) {
return "";
}
if (typeof value === "string") {
return value;
}
if (!isObjectRecord(value)) {
return "";
}
for (const key of keys) {
const direct = value[key];
if (typeof direct === "string" && direct.trim()) {
return direct;
}
if (isObjectRecord(direct)) {
const nested = findStringDeep(direct, keys, depth + 1);
if (nested) {
return nested;
}
}
}
for (const nestedValue of Object.values(value)) {
if (Array.isArray(nestedValue)) {
continue;
}
const nested = findStringDeep(nestedValue, keys, depth + 1);
if (nested) {
return nested;
}
}
return "";
}
export function extractObjectPath(value: unknown): string {
if (typeof value === "string") {
return value;
}
return findStringDeep(value, [
"objectPath",
"ObjectPath",
"path",
"Path",
"actorPath",
"ActorPath",
"softObjectPath",
"SoftObjectPath",
"name",
"Name",
]);
}
export function nestedString(source: RemoteObject, keys: string[], fallback = ""): string {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) {
return value;
}
const nested = asRecord(value);
const nestedValue = firstString(nested, ["Name", "name", "Path", "path", "ObjectPath", "objectPath"]);
if (nestedValue) {
return nestedValue;
}
}
return fallback;
}
export function basename(path: string): string {
const clean = path.replace(/["']/g, "");
const colonName = clean.split(":").pop() ?? clean;
const dotName = colonName.split(".").pop() ?? colonName;
const slashName = dotName.split("/").pop() ?? dotName;
return slashName || clean || "Actor";
}
export function cleanObjectName(name: string): string {
return name
.replace(/^Default__/, "")
.replace(/^UEDPIE_\d+_/, "")
.replace(/_\d+$/, (suffix) => (suffix === "_0" ? "_0" : suffix));
}
export function deriveLevelNameFromPath(path: string): string {
if (!path) {
return "";
}
const clean = path.replace(/["']/g, "");
const packagePath = clean.includes(":") ? clean.split(":")[0] : clean;
const objectName = packagePath.split(".").pop() ?? packagePath;
return cleanObjectName(basename(objectName));
}
export function extractReturnValue(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
const record = asRecord(payload);
const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result;
if (Array.isArray(returnValue)) {
return returnValue;
}
const nested = asRecord(returnValue);
for (const value of Object.values(nested)) {
if (Array.isArray(value)) {
return value;
}
}
return [];
}
export function extractFirstReturnString(payload: unknown): string {
const record = asRecord(payload);
const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result;
if (typeof returnValue === "string") {
return returnValue;
}
if (typeof returnValue === "number") {
return String(returnValue);
}
return firstString(asRecord(returnValue), ["Name", "name", "Label", "label", "DisplayName", "displayName"]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import type { ActorRow, TreeNode } from "../types";
function folderSegments(path: string): string[] {
return path
.split(/[\\/|]/)
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "none");
}
function getOrCreateFolder(parent: TreeNode, label: string, level: string): TreeNode {
const existing = parent.children.find((child) => child.kind === "folder" && child.label === label);
if (existing) {
return existing;
}
const folder: TreeNode = {
id: `${parent.id}/folder/${label}`,
label,
type: "Folder",
level,
idName: "",
kind: "folder",
children: [],
};
parent.children.push(folder);
return folder;
}
export function buildTree(actors: ActorRow[]): TreeNode {
const rootLevelName = actors.find((actor) => actor.level && actor.level !== "Persistent Level")?.level;
const world: TreeNode = {
id: "world",
label: `${rootLevelName || "Open World"} (Editor)`,
type: "World",
level: "",
idName: "",
kind: "world",
children: [],
};
for (const actor of actors) {
let parent = world;
for (const segment of folderSegments(actor.folderPath)) {
parent = getOrCreateFolder(parent, segment, actor.level);
}
parent.children.push({
...actor,
kind: "actor",
children: [],
});
}
const sortNode = (node: TreeNode) => {
node.children.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "folder" ? -1 : 1;
}
return a.label.localeCompare(b.label);
});
node.children.forEach(sortNode);
};
sortNode(world);
return world;
}
export function visibleCount(node: TreeNode): number {
return node.children.reduce((total, child) => total + (child.kind === "actor" ? 1 : visibleCount(child)), 0);
}
export function findNodeById(node: TreeNode, id: string | null): TreeNode | null {
if (!id) {
return null;
}
if (node.id === id) {
return node;
}
for (const child of node.children) {
const found = findNodeById(child, id);
if (found) {
return found;
}
}
return null;
}
export function nodeMatchesQuery(node: TreeNode, query: string): boolean {
if (!query) return true;
const lower = query.toLowerCase();
const selfMatches = [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(lower));
return selfMatches || node.children.some((child) => nodeMatchesQuery(child, query));
}

157
src/services/details.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { DetailProperty, ObjectDetails, RemoteObject } from "../types";
import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues";
function getMetadataString(property: RemoteObject, key: string): string {
const metadata = asRecord(property.Metadata ?? property.metadata);
return firstString(metadata, [key, key.toLowerCase(), key.toUpperCase()]);
}
function prettifyPropertyName(name: string): string {
return name
.replace(/^b([A-Z])/, "$1")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim();
}
function findPropertyValue(values: RemoteObject, name: string): unknown {
if (name in values) {
return values[name];
}
const lowerName = name.toLowerCase();
const key = Object.keys(values).find((candidate) => candidate.toLowerCase() === lowerName);
return key ? values[key] : undefined;
}
function extractPropertyValues(payload: unknown): RemoteObject {
const record = asRecord(payload);
const candidates = [
record,
asRecord(record.ResponseBody),
asRecord(record.responseBody),
asRecord(record.Properties),
asRecord(record.properties),
];
for (const candidate of candidates) {
const keys = Object.keys(candidate);
if (keys.length > 0 && !keys.includes("ResponseCode") && !keys.includes("RequestId")) {
return candidate;
}
}
return {};
}
export function normalizeDetails(describePayload: unknown, propertyPayload: unknown): ObjectDetails {
const description = asRecord(describePayload);
const values = extractPropertyValues(propertyPayload);
const properties = Array.isArray(description.Properties)
? description.Properties
: Array.isArray(description.properties)
? description.properties
: [];
return {
name: firstString(description, ["Name", "name"], "Object"),
className: firstString(description, ["Class", "class"], "UObject"),
properties: properties.map((rawProperty): DetailProperty => {
const property = asRecord(rawProperty);
const name = firstString(property, ["Name", "name"], "Property");
return {
name,
displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name),
category: getMetadataString(property, "Category") || "General",
type: firstString(property, ["Type", "type"], "Property"),
value: findPropertyValue(values, name),
description: firstString(property, ["Description", "description"]),
};
}),
};
}
export function formatDetailValue(value: unknown): string {
if (value === undefined) {
return "Unavailable";
}
if (value === null) {
return "None";
}
if (typeof value === "boolean") {
return value ? "True" : "False";
}
if (typeof value === "number") {
return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/\.?0+$/, "");
}
if (typeof value === "string") {
return value || "None";
}
if (Array.isArray(value)) {
return `${value.length} item${value.length === 1 ? "" : "s"}`;
}
const record = asRecord(value);
const vectorKeys = ["X", "Y", "Z"].filter((key) => key in record);
if (vectorKeys.length === 3) {
return vectorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" ");
}
const rotatorKeys = ["Roll", "Pitch", "Yaw"].filter((key) => key in record);
if (rotatorKeys.length === 3) {
return rotatorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" ");
}
const objectPath = extractObjectPath(value);
if (objectPath) {
return cleanObjectName(basename(objectPath));
}
return JSON.stringify(value);
}
export function detailAssetPath(property: DetailProperty): string {
const valuePath = extractObjectPath(property.value);
if (valuePath.startsWith("/Game/") || valuePath.startsWith("/Engine/")) {
return valuePath;
}
if (typeof property.value === "string" && (property.value.startsWith("/Game/") || property.value.startsWith("/Engine/"))) {
return property.value;
}
return "";
}
export function isBooleanProperty(property: DetailProperty): boolean {
return typeof property.value === "boolean" || property.type.toLowerCase().includes("bool");
}
export function isVectorLikeProperty(property: DetailProperty): boolean {
if (!property.value || typeof property.value !== "object" || Array.isArray(property.value)) {
return false;
}
const record = asRecord(property.value);
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
}
export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
const record = asRecord(value);
const keys = ["X", "Y", "Z"].every((key) => key in record) ? ["X", "Y", "Z"] : ["Roll", "Pitch", "Yaw"];
return keys.map((key) => ({ label: key, value: formatDetailValue(record[key]) }));
}
export function normalizeThumbnailPayload(payload: unknown): string {
if (typeof payload === "string") {
return payload.startsWith("data:") ? payload : `data:image/png;base64,${payload}`;
}
const record = asRecord(payload);
const value = firstString(record, ["Thumbnail", "thumbnail", "Image", "image", "Data", "data", "Base64", "base64"]);
if (!value) {
return "";
}
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
}

View File

@@ -0,0 +1,108 @@
import type { CachedObjectDetails, ObjectDetails } from "../types";
const CACHE_PREFIX = "unreal-outliner.details.";
const CACHE_VERSION = "v1";
const CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_CACHE_ENTRIES = 150;
function cacheKey(objectPath: string): string {
return `${CACHE_PREFIX}${CACHE_VERSION}.${objectPath}`;
}
function isCachedObjectDetails(value: unknown): value is CachedObjectDetails {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as CachedObjectDetails;
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && Boolean(candidate.details);
}
function storageAvailable(): boolean {
try {
const key = `${CACHE_PREFIX}probe`;
window.localStorage.setItem(key, "1");
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
export function readCachedDetails(objectPath: string): ObjectDetails | null {
if (!storageAvailable()) {
return null;
}
try {
const raw = window.localStorage.getItem(cacheKey(objectPath));
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!isCachedObjectDetails(parsed) || parsed.objectPath !== objectPath) {
return null;
}
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
window.localStorage.removeItem(cacheKey(objectPath));
return null;
}
return parsed.details;
} catch {
return null;
}
}
export function writeCachedDetails(objectPath: string, details: ObjectDetails): void {
if (!storageAvailable()) {
return;
}
try {
const entry: CachedObjectDetails = {
objectPath,
savedAt: Date.now(),
details,
};
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify(entry));
pruneDetailsCache();
} catch {
pruneDetailsCache();
}
}
function pruneDetailsCache(): void {
const entries: Array<{ key: string; savedAt: number }> = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (!key?.startsWith(`${CACHE_PREFIX}${CACHE_VERSION}.`)) {
continue;
}
try {
const parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
if (isCachedObjectDetails(parsed)) {
entries.push({ key, savedAt: parsed.savedAt });
}
} catch {
window.localStorage.removeItem(key);
}
}
const expiredBefore = Date.now() - CACHE_TTL_MS;
for (const entry of entries) {
if (entry.savedAt < expiredBefore) {
window.localStorage.removeItem(entry.key);
}
}
entries
.filter((entry) => entry.savedAt >= expiredBefore)
.sort((a, b) => b.savedAt - a.savedAt)
.slice(MAX_CACHE_ENTRIES)
.forEach((entry) => window.localStorage.removeItem(entry.key));
}

View File

@@ -0,0 +1,229 @@
import type { RemoteObject, RemoteTransport } from "../types";
import { asRecord, firstString } from "../lib/remoteValues";
const UNREAL_WS_URL = import.meta.env.VITE_UNREAL_WS_URL ?? "ws://127.0.0.1:30020";
export class RemoteCallError extends Error {
constructor(
message: string,
readonly isTransportError = false,
) {
super(message);
}
}
let activeTransport: RemoteTransport = "HTTP";
let remoteSocket: WebSocket | null = null;
let remoteSocketPromise: Promise<WebSocket> | null = null;
let nextRequestId = 1;
const pendingSocketCalls = new Map<
number,
{
resolve: (payload: unknown) => void;
reject: (reason: Error) => void;
timeoutId: number;
}
>();
export function getActiveTransport(): RemoteTransport {
return activeTransport;
}
async function parseRemoteResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
function stringifyRemoteError(payload: unknown): string {
if (typeof payload === "string") {
return payload;
}
const record = asRecord(payload);
const direct = firstString(record, ["errorMessage", "ErrorMessage", "message", "Message", "error", "Error"]);
if (direct) {
return direct;
}
return JSON.stringify(payload, null, 2);
}
function closeRemoteSocket(reason: Error) {
for (const call of pendingSocketCalls.values()) {
window.clearTimeout(call.timeoutId);
call.reject(reason);
}
pendingSocketCalls.clear();
remoteSocket = null;
remoteSocketPromise = null;
}
function parseSocketMessage(data: string | Blob): Promise<RemoteObject> {
if (typeof data === "string") {
return Promise.resolve(asRecord(JSON.parse(data)));
}
return data.text().then((text) => asRecord(JSON.parse(text)));
}
function connectRemoteSocket(): Promise<WebSocket> {
if (remoteSocket?.readyState === WebSocket.OPEN) {
return Promise.resolve(remoteSocket);
}
if (remoteSocketPromise) {
return remoteSocketPromise;
}
remoteSocketPromise = new Promise((resolve, reject) => {
const socket = new WebSocket(UNREAL_WS_URL);
const timeoutId = window.setTimeout(() => {
socket.close();
reject(new RemoteCallError(`Timed out connecting to ${UNREAL_WS_URL}`, true));
remoteSocketPromise = null;
}, 1500);
socket.onopen = () => {
window.clearTimeout(timeoutId);
remoteSocket = socket;
activeTransport = "WebSocket";
resolve(socket);
};
socket.onerror = () => {
window.clearTimeout(timeoutId);
reject(new RemoteCallError(`Could not connect to ${UNREAL_WS_URL}`, true));
remoteSocket = null;
remoteSocketPromise = null;
};
socket.onclose = () => {
closeRemoteSocket(new RemoteCallError("Unreal Remote Control WebSocket closed", true));
};
socket.onmessage = (message) => {
parseSocketMessage(message.data)
.then((payload) => {
const requestId = Number(payload.RequestId ?? payload.Id ?? payload.id);
const pending = pendingSocketCalls.get(requestId);
if (!pending) {
return;
}
pendingSocketCalls.delete(requestId);
window.clearTimeout(pending.timeoutId);
const responseCode = Number(payload.ResponseCode ?? payload.responseCode ?? 200);
const responseBody = payload.ResponseBody ?? payload.responseBody ?? payload;
if (responseCode >= 200 && responseCode < 300) {
pending.resolve(responseBody);
} else {
pending.reject(new RemoteCallError(`Remote Control returned ${responseCode}: ${stringifyRemoteError(responseBody)}`));
}
})
.catch((reason) => {
console.warn("Could not parse Unreal Remote Control WebSocket message", reason);
});
};
});
return remoteSocketPromise;
}
async function callRemoteHttpRouteHttp(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise<unknown> {
activeTransport = "HTTP";
const response = await fetch(url, {
method: verb,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await parseRemoteResponse(response);
if (!response.ok) {
throw new RemoteCallError(`Remote Control returned ${response.status} ${response.statusText}: ${stringifyRemoteError(payload)}`);
}
return payload;
}
async function callRemoteHttpRouteWs(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise<unknown> {
const socket = await connectRemoteSocket();
const requestId = nextRequestId++;
const response = new Promise<unknown>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
pendingSocketCalls.delete(requestId);
reject(new RemoteCallError(`Timed out waiting for WebSocket response ${requestId}`, true));
}, 8000);
pendingSocketCalls.set(requestId, { resolve, reject, timeoutId });
});
try {
socket.send(
JSON.stringify({
MessageName: "http",
Id: requestId,
Parameters: {
Url: url,
Verb: verb,
Body: body,
},
}),
);
} catch (reason) {
pendingSocketCalls.delete(requestId);
throw new RemoteCallError(reason instanceof Error ? reason.message : "Could not send WebSocket request", true);
}
return response;
}
export async function callRemoteHttpRoute(
url: string,
verb: "GET" | "PUT" | "POST" | "DELETE",
body?: RemoteObject,
): Promise<unknown> {
try {
return await callRemoteHttpRouteWs(url, verb, body);
} catch (reason) {
if (reason instanceof RemoteCallError && reason.isTransportError) {
return callRemoteHttpRouteHttp(url, verb, body);
}
throw reason;
}
}
export async function callRemoteFunction(objectPath: string, functionName: string, parameters: RemoteObject = {}): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/call", "PUT", {
objectPath,
functionName,
parameters,
generateTransaction: false,
});
}
export async function describeRemoteObject(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/describe", "PUT", { objectPath });
}
export async function readRemoteObjectProperties(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/property", "PUT", {
objectPath,
access: "READ_ACCESS",
});
}
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
}

View File

@@ -0,0 +1,106 @@
const CACHE_PREFIX = "unreal-outliner.thumbnail.v1.";
const CACHE_TTL_MS = 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 100;
type CachedThumbnail = {
objectPath: string;
savedAt: number;
dataUrl: string;
};
function cacheKey(objectPath: string): string {
return `${CACHE_PREFIX}${objectPath}`;
}
function isCachedThumbnail(value: unknown): value is CachedThumbnail {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as CachedThumbnail;
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && typeof candidate.dataUrl === "string";
}
function storageAvailable(): boolean {
try {
const key = `${CACHE_PREFIX}probe`;
window.localStorage.setItem(key, "1");
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
export function readCachedThumbnail(objectPath: string): string {
if (!storageAvailable()) {
return "";
}
try {
const raw = window.localStorage.getItem(cacheKey(objectPath));
if (!raw) {
return "";
}
const parsed = JSON.parse(raw);
if (!isCachedThumbnail(parsed) || parsed.objectPath !== objectPath) {
return "";
}
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
window.localStorage.removeItem(cacheKey(objectPath));
return "";
}
return parsed.dataUrl;
} catch {
return "";
}
}
export function writeCachedThumbnail(objectPath: string, dataUrl: string): void {
if (!storageAvailable() || !dataUrl) {
return;
}
try {
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify({ objectPath, savedAt: Date.now(), dataUrl }));
pruneThumbnailCache();
} catch {
pruneThumbnailCache();
}
}
function pruneThumbnailCache(): void {
const entries: Array<{ key: string; savedAt: number }> = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (!key?.startsWith(CACHE_PREFIX)) {
continue;
}
try {
const parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
if (isCachedThumbnail(parsed)) {
entries.push({ key, savedAt: parsed.savedAt });
}
} catch {
window.localStorage.removeItem(key);
}
}
const expiredBefore = Date.now() - CACHE_TTL_MS;
for (const entry of entries) {
if (entry.savedAt < expiredBefore) {
window.localStorage.removeItem(entry.key);
}
}
entries
.filter((entry) => entry.savedAt >= expiredBefore)
.sort((a, b) => b.savedAt - a.savedAt)
.slice(MAX_CACHE_ENTRIES)
.forEach((entry) => window.localStorage.removeItem(entry.key));
}

View File

@@ -0,0 +1,138 @@
import type { ActorRow, RemoteObject } from "../types";
import {
asRecord,
basename,
cleanObjectName,
deriveLevelNameFromPath,
extractFirstReturnString,
extractObjectPath,
firstString,
nestedString,
} from "../lib/remoteValues";
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem";
const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary";
const disabledActorFunctions = new Set<string>();
const actorStringCallCache = new Map<string, string>();
function deriveType(actor: RemoteObject): string {
const direct = firstString(actor, ["Class", "class", "ClassName", "className", "Type", "type"]);
if (direct) {
return basename(direct).replace(/^BP_/, "");
}
const path = firstString(actor, ["Path", "path", "ObjectPath", "objectPath"]);
const generatedClass = /\.(.+?)_C_\d+$/.exec(path)?.[1];
return generatedClass ? basename(generatedClass) : "Actor";
}
export function normalizeActor(value: unknown, index: number): ActorRow {
const actor = asRecord(value);
const objectPath = extractObjectPath(value);
const pathName = cleanObjectName(basename(objectPath));
const label = firstString(actor, ["ActorLabel", "actorLabel", "Label", "label", "DisplayName", "displayName"], pathName || `Actor ${index + 1}`);
const idName = firstString(actor, ["Name", "name", "IdName", "idName"], pathName || label);
const level =
deriveLevelNameFromPath(objectPath) ||
nestedString(actor, ["Level", "level", "Outer", "outer", "Package", "package"], "Persistent Level");
const folderPath = firstString(actor, ["FolderPath", "folderPath", "Folder", "folder", "ActorFolderPath", "actorFolderPath"]);
return {
id: objectPath || `${label}-${index}`,
label,
type: deriveType(actor),
level: deriveLevelNameFromPath(level) || basename(level).replace(/^UEDPIE_\d+_/, "") || "Persistent Level",
idName,
folderPath,
objectPath,
};
}
function mergeActorDetails(actor: ActorRow, details: Partial<ActorRow>): ActorRow {
return {
...actor,
label: details.label && details.label !== "Actor" ? details.label : actor.label,
type: details.type && details.type !== "Actor" ? details.type : actor.type,
level: details.level && details.level !== "Persistent Level" ? details.level : actor.level,
idName: details.idName && details.idName !== "Actor" ? details.idName : actor.idName,
folderPath: details.folderPath || actor.folderPath,
};
}
export async function getAllLevelActors(): Promise<unknown> {
const attempts = [
{ objectPath: EDITOR_ACTOR_SUBSYSTEM, functionName: "GetAllLevelActors" },
{ objectPath: EDITOR_LEVEL_LIBRARY, functionName: "GetAllLevelActors" },
];
const errors: string[] = [];
for (const attempt of attempts) {
try {
return await callRemoteFunction(attempt.objectPath, attempt.functionName);
} catch (reason) {
errors.push(`${attempt.objectPath}.${attempt.functionName}: ${reason instanceof Error ? reason.message : String(reason)}`);
}
}
throw new Error(errors.join("\n\n"));
}
async function tryRemoteString(objectPath: string, functionName: string): Promise<string> {
if (disabledActorFunctions.has(functionName)) {
return "";
}
const cacheKey = `${objectPath}|${functionName}`;
if (actorStringCallCache.has(cacheKey)) {
return actorStringCallCache.get(cacheKey) ?? "";
}
try {
const value = extractFirstReturnString(await callRemoteFunction(objectPath, functionName));
actorStringCallCache.set(cacheKey, value);
return value;
} catch (reason) {
if (reason instanceof RemoteCallError && !reason.isTransportError) {
disabledActorFunctions.add(functionName);
}
actorStringCallCache.set(cacheKey, "");
return "";
}
}
async function enrichActor(actor: ActorRow): Promise<ActorRow> {
if (!actor.objectPath) {
return actor;
}
const [label, folderPath] = await Promise.all([
tryRemoteString(actor.objectPath, "GetActorLabel"),
tryRemoteString(actor.objectPath, "GetFolderPath"),
]);
const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : "";
return mergeActorDetails(actor, {
label: label || actor.label,
folderPath: folderPath || actor.folderPath,
level: deriveLevelNameFromPath(actor.objectPath) || actor.level,
type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type,
});
}
export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> {
const sampleActor = actors.find((actor) => actor.objectPath);
if (sampleActor) {
await Promise.all([
tryRemoteString(sampleActor.objectPath, "GetActorLabel"),
tryRemoteString(sampleActor.objectPath, "GetFolderPath"),
]);
}
const enriched: ActorRow[] = [];
for (let index = 0; index < actors.length; index += 4) {
enriched.push(...(await Promise.all(actors.slice(index, index + 4).map(enrichActor))));
}
return enriched;
}

View File

@@ -1,6 +1,7 @@
:root {
color-scheme: dark;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 85%;
background: #101010;
color: #c7c7c7;
}
@@ -23,16 +24,16 @@ input {
.shell {
min-height: 100vh;
padding: 10px;
padding: 8px;
background: #101010;
}
.panel {
width: min(1220px, 100%);
min-height: calc(100vh - 20px);
height: calc(100vh - 20px);
min-height: calc(100vh - 16px);
height: calc(100vh - 16px);
display: grid;
grid-template-rows: 48px 56px 36px 1fr 50px;
grid-template-rows: 41px 48px 31px 1fr 43px;
border: 1px solid #0a0a0a;
background: #171717;
box-shadow: inset 0 0 0 1px #232323;
@@ -41,7 +42,7 @@ input {
.tabs {
display: flex;
align-items: stretch;
height: 48px;
height: 41px;
background: #1f1f1f;
border-bottom: 1px solid #0c0c0c;
}
@@ -49,9 +50,9 @@ input {
.tab {
display: flex;
align-items: center;
gap: 10px;
min-width: 240px;
padding: 0 14px;
gap: 9px;
min-width: 204px;
padding: 0 12px;
color: #bdbdbd;
font-size: 20px;
border-right: 1px solid #111;
@@ -69,16 +70,16 @@ input {
.toolbar {
display: grid;
grid-template-columns: 36px 30px minmax(160px, 1fr) 36px 36px 36px;
grid-template-columns: 31px 26px minmax(136px, 1fr) 31px 31px 31px;
align-items: center;
gap: 8px;
padding: 7px 12px;
gap: 7px;
padding: 6px 10px;
background: #262626;
}
.icon-button {
width: 32px;
height: 32px;
width: 27px;
height: 27px;
display: inline-grid;
place-items: center;
border: 0;
@@ -99,12 +100,12 @@ input {
}
.search {
height: 36px;
height: 31px;
min-width: 0;
display: grid;
grid-template-columns: 30px 1fr 24px;
grid-template-columns: 26px 1fr 20px;
align-items: center;
padding: 0 8px;
padding: 0 7px;
color: #cfcfcf;
background: #0d0d0d;
border: 1px solid #343434;
@@ -129,7 +130,7 @@ input {
background: transparent;
border: 0;
outline: 0;
font-size: 20px;
font-size: 1rem;
}
.search input::placeholder {
@@ -138,11 +139,11 @@ input {
.columns {
display: grid;
grid-template-columns: 38px minmax(260px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
grid-template-columns: 32px minmax(221px, 1.3fr) minmax(119px, 0.55fr) minmax(111px, 0.52fr) minmax(128px, 0.62fr);
align-items: center;
background: #303030;
color: #c7c7c7;
font-size: 20px;
font-size: 1rem;
}
.columns span {
@@ -162,10 +163,44 @@ input {
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: minmax(180px, 1fr) minmax(250px, 0.9fr);
grid-template-rows:
minmax(120px, calc(var(--outliner-ratio) * 100%))
8px
minmax(190px, calc(var(--details-ratio) * 100%));
background: #171717;
}
.pane-splitter {
position: relative;
display: grid;
place-items: center;
min-height: 8px;
background: #101010;
border-top: 1px solid #2e2e2e;
border-bottom: 1px solid #060606;
cursor: row-resize;
touch-action: none;
}
.pane-splitter span {
width: 54px;
height: 3px;
border-radius: 999px;
background: #565656;
box-shadow: 0 1px 0 #050505;
}
.pane-splitter:hover,
.pane-splitter:focus-visible {
background: #171f27;
outline: none;
}
.pane-splitter:hover span,
.pane-splitter:focus-visible span {
background: #2d86d8;
}
.rows {
min-height: 0;
overflow: auto;
@@ -174,10 +209,10 @@ input {
.outliner-row {
width: 100%;
min-width: 760px;
height: 30px;
min-width: 646px;
height: 26px;
display: grid;
grid-template-columns: minmax(298px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
grid-template-columns: minmax(253px, 1.3fr) minmax(119px, 0.55fr) minmax(111px, 0.52fr) minmax(128px, 0.62fr);
align-items: center;
color: #bebebe;
background: transparent;
@@ -202,20 +237,20 @@ input {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 12px;
font-size: 20px;
padding: 0 10px;
font-size: 1rem;
color: inherit;
}
.item-cell {
display: flex;
align-items: center;
gap: 5px;
padding-left: 42px;
gap: 4px;
padding-left: 36px;
}
.indent {
width: calc(var(--depth) * 18px);
width: calc(var(--depth) * 15px);
flex: 0 0 auto;
}
@@ -254,34 +289,34 @@ input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 20px;
gap: 14px;
padding: 0 17px;
color: #cfcfcf;
background: #303030;
font-size: 20px;
font-size: 1rem;
}
.empty {
min-height: 220px;
min-height: 187px;
display: grid;
align-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
gap: 9px;
padding: 20px;
color: #bdbdbd;
text-align: center;
font-size: 16px;
font-size: 0.85rem;
}
.empty strong {
color: #f0f0f0;
font-size: 22px;
font-size: 1.1rem;
}
.details-panel {
min-height: 0;
display: grid;
grid-template-rows: 38px 58px 44px 1fr;
grid-template-rows: 32px 41px 100px 37px 34px 1fr;
border-top: 2px solid #0d0d0d;
background: #202020;
color: #cfcfcf;
@@ -296,11 +331,11 @@ input {
.details-tab {
display: flex;
align-items: center;
gap: 9px;
min-width: 210px;
padding: 0 12px;
gap: 8px;
min-width: 179px;
padding: 0 10px;
color: #c7c7c7;
font-size: 20px;
font-size: 1rem;
background: #232323;
border-right: 1px solid #101010;
}
@@ -320,10 +355,10 @@ input {
.details-object {
display: grid;
grid-template-columns: 28px 1fr;
grid-template-columns: 24px 1fr;
align-items: center;
gap: 10px;
padding: 9px 14px;
gap: 9px;
padding: 6px 12px;
border-bottom: 1px solid #171717;
background: #242424;
}
@@ -339,32 +374,79 @@ input {
.details-object strong {
color: #f1f1f1;
font-size: 18px;
font-size: 0.95rem;
font-weight: 500;
}
.details-object span {
margin-top: 2px;
color: #a8a8a8;
font-size: 14px;
font-size: 0.82rem;
}
.component-list {
min-height: 0;
padding: 5px 7px 7px;
background: #181818;
border: 1px solid #242424;
border-left: 0;
border-right: 0;
overflow: hidden;
}
.component-row {
height: 29px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 9px;
color: #d2d2d2;
font-size: 0.95rem;
}
.component-row.selected {
background: #3d5770;
color: #f1f1f1;
}
.component-row.child {
padding-left: 36px;
background: #151515;
color: #c9c9c9;
font-size: 0.85rem;
}
.component-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-row a {
margin-left: auto;
color: #d9d9d9;
text-decoration: underline dotted;
text-underline-offset: 3px;
white-space: nowrap;
}
.details-search-row {
display: grid;
grid-template-columns: minmax(140px, 1fr) 34px;
grid-template-columns: minmax(119px, 1fr) 29px 29px 29px;
align-items: center;
gap: 8px;
padding: 6px 12px;
gap: 7px;
padding: 5px 10px;
border-bottom: 1px solid #141414;
background: #262626;
}
.details-search {
height: 31px;
height: 26px;
display: grid;
grid-template-columns: 26px 1fr;
grid-template-columns: 22px 1fr;
align-items: center;
padding: 0 8px;
padding: 0 7px;
color: #cfcfcf;
background: #0d0d0d;
border: 1px solid #343434;
@@ -378,12 +460,12 @@ input {
background: transparent;
border: 0;
outline: 0;
font-size: 17px;
font-size: 0.95rem;
}
.details-tool {
width: 30px;
height: 30px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
@@ -396,6 +478,38 @@ input {
background: #363636;
}
.category-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #262626;
border-bottom: 1px solid #141414;
overflow-x: auto;
}
.category-tabs button {
height: 25px;
min-width: 71px;
padding: 0 12px;
color: #c7c7c7;
background: #2c2c2c;
border: 1px solid #151515;
border-radius: 5px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.category-tabs button:hover {
background: #343434;
}
.category-tabs button.active {
color: #ffffff;
background: #0877d9;
border-color: #0a5ea9;
}
.details-categories {
min-height: 0;
overflow: auto;
@@ -403,24 +517,24 @@ input {
}
.detail-category h3 {
height: 32px;
height: 27px;
display: flex;
align-items: center;
gap: 6px;
gap: 5px;
margin: 0;
padding: 0 10px;
padding: 0 9px;
color: #d0d0d0;
background: #303030;
border-top: 1px solid #393939;
border-bottom: 1px solid #171717;
font-size: 16px;
font-size: 0.9rem;
font-weight: 600;
}
.detail-row {
min-height: 40px;
min-height: 34px;
display: grid;
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr);
grid-template-columns: minmax(153px, 0.48fr) minmax(187px, 0.52fr);
border-bottom: 1px solid #171717;
}
@@ -432,18 +546,172 @@ input {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 14px;
font-size: 16px;
padding: 0 12px;
font-size: 0.9rem;
}
.detail-name {
color: #c8c8c8;
border-right: 1px solid #151515;
background: #242424;
}
.detail-value {
color: #e0e0e0;
background: #242424;
gap: 7px;
padding: 4px 9px 4px 12px;
}
.text-value {
min-width: 0;
width: min(323px, 100%);
height: 24px;
display: flex;
align-items: center;
padding: 0 9px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505;
}
.text-value span,
.asset-control span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vector-value {
min-width: 0;
width: min(357px, 100%);
display: grid;
grid-template-columns: repeat(3, minmax(54px, 1fr));
gap: 4px;
}
.axis {
height: 24px;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
padding: 0 7px;
color: #f0f0f0;
background: #101010;
border: 1px solid #333;
border-left-width: 4px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505;
white-space: nowrap;
}
.axis-0 {
border-left-color: #df3b1f;
}
.axis-1 {
border-left-color: #76b900;
}
.axis-2 {
border-left-color: #0d7fe8;
}
.checkbox-value {
width: 22px;
height: 22px;
border: 1px solid #353535;
border-radius: 4px;
background: #111;
box-shadow: inset 0 0 0 1px #050505;
}
.checkbox-value.checked::after {
content: "";
display: block;
width: 11px;
height: 6px;
margin: 6px 0 0 4px;
border-left: 2px solid #d7d7d7;
border-bottom: 2px solid #d7d7d7;
transform: rotate(-45deg);
}
.asset-value {
min-width: 0;
width: min(366px, 100%);
display: grid;
grid-template-columns: 46px minmax(102px, 1fr);
align-items: center;
gap: 7px;
}
.asset-thumb {
width: 46px;
height: 46px;
display: grid;
place-items: center;
color: #bdbdbd;
background:
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%),
#101010;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-size: 16px 16px;
border: 1px solid #313131;
border-radius: 5px;
box-shadow: inset 0 -3px 0 #16bfc4;
overflow: hidden;
}
.asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-control {
min-width: 0;
height: 26px;
display: grid;
grid-template-columns: minmax(0, 1fr) 17px;
align-items: center;
padding: 0 7px 0 9px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 5px;
box-shadow: inset 0 0 0 1px #050505;
}
.reset-button {
width: 22px;
height: 22px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
color: #bcbcbc;
background: transparent;
border: 0;
border-radius: 3px;
}
.reset-button:hover {
background: #343434;
}
.row-reset {
margin-left: auto;
}
.details-empty {
@@ -475,11 +743,18 @@ input {
min-height: 100vh;
height: 100vh;
border: 0;
grid-template-rows: 44px 52px 34px 1fr 44px;
grid-template-rows: 37px 44px 29px 1fr 37px;
}
.main-content {
grid-template-rows: minmax(170px, 1fr) minmax(260px, 0.95fr);
grid-template-rows:
minmax(120px, calc(var(--outliner-ratio) * 100%))
10px
minmax(220px, calc(var(--details-ratio) * 100%));
}
.pane-splitter {
min-height: 10px;
}
.tab {

50
src/types.ts Normal file
View File

@@ -0,0 +1,50 @@
export type RemoteObject = Record<string, unknown>;
export type RemoteTransport = "WebSocket" | "HTTP";
export type ActorRow = {
id: string;
label: string;
type: string;
level: string;
idName: string;
folderPath: string;
objectPath: string;
};
export type ActorReference = {
raw: unknown;
objectPath: string;
};
export type TreeNode = {
id: string;
label: string;
type: string;
level: string;
idName: string;
objectPath?: string;
kind: "world" | "folder" | "actor";
children: TreeNode[];
};
export type DetailProperty = {
name: string;
displayName: string;
category: string;
type: string;
value: unknown;
description: string;
};
export type ObjectDetails = {
name: string;
className: string;
properties: DetailProperty[];
};
export type CachedObjectDetails = {
objectPath: string;
savedAt: number;
details: ObjectDetails;
};