0.3.7 — Unreal plugin renders native: right-click actions + dock tab, real icons
Plugin contributions gain named host icons, a working-folder context slot, and bottom-dock panels. The Unreal plugin (1.2.0) now puts Launch / Build Solution in the folder right-click menu with the UE + hammer icons and its log in a bottom-dock "Unreal" tab — where they lived before extraction — instead of emoji entries in Tools. Plugin is backward-compatible (falls back to menu/view on 0.3.6 hosts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
52
src/App.tsx
52
src/App.tsx
@ -12,7 +12,7 @@ import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
||||
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
||||
|
||||
// native OS notification (works even when the window is hidden in the tray)
|
||||
@ -82,6 +82,13 @@ const I = {
|
||||
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
// Resolve a plugin-declared icon NAME to a real SVG from the host set, so plugin
|
||||
// contributions render with native icons instead of a generic glyph.
|
||||
function pluginIcon(name?: string): ReactNode {
|
||||
if (name && Object.prototype.hasOwnProperty.call(I, name)) return (I as Record<string, ReactNode>)[name];
|
||||
return I.hex;
|
||||
}
|
||||
|
||||
/* ---------------- theme hook (built-in + custom themes) ---------------- */
|
||||
function useTheme(): [boolean, () => void, string, (id: string) => void] {
|
||||
const [themeId, setThemeId] = useState(() => loadActiveId());
|
||||
@ -413,9 +420,9 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
// bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
|
||||
// bottom dock: a tabbed panel (Log / Terminal + any plugin dock panels), opened from Window menu
|
||||
const [dockOpen, setDockOpen] = useState(false);
|
||||
const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
|
||||
const [dockTab, setDockTab] = useState<string>("log");
|
||||
// right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab.
|
||||
const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => {
|
||||
try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {}
|
||||
@ -484,7 +491,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
}
|
||||
|
||||
// open the bottom dock on a given tab (toggle off if already showing it)
|
||||
function openDock(tab: "log" | "terminal" | "unreal") {
|
||||
function openDock(tab: string) {
|
||||
setDockOpen((o) => (o && dockTab === tab ? false : true));
|
||||
setDockTab(tab);
|
||||
}
|
||||
@ -629,6 +636,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
hasProject: () => !!uprojectRef.current,
|
||||
uproject: () => uprojectRef.current,
|
||||
build: () => { startBuild(); },
|
||||
launch: () => { void launchUE(); },
|
||||
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
|
||||
},
|
||||
});
|
||||
@ -1286,7 +1294,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
||||
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
|
||||
{ label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
|
||||
...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: I.hex, act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
|
||||
...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: pluginIcon(fi.icon), act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
|
||||
]);
|
||||
}
|
||||
async function lockFiles(dps: string[], lock: boolean) {
|
||||
@ -1397,6 +1405,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
|
||||
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
|
||||
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
|
||||
...getRegistry().docks.map((d) => ({ label: (dockOpen && dockTab === `plugin:${d.id}` ? "✓ " : "") + d.title, icon: pluginIcon(d.icon), hint: t("From plugin: {p}", { p: d.pluginId }), act: () => openDock(`plugin:${d.id}`) })),
|
||||
],
|
||||
Tools: [
|
||||
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
|
||||
@ -1407,7 +1416,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
|
||||
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
|
||||
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
|
||||
...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
|
||||
...getRegistry().menu.map((m) => ({ label: m.label, icon: pluginIcon(m.icon), hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
|
||||
...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })),
|
||||
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
|
||||
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
|
||||
@ -1460,7 +1469,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []),
|
||||
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
|
||||
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
|
||||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||||
...getRegistry().folderItems.filter((f) => !f.visible || f.visible()).map((f) => ({ label: f.label, icon: pluginIcon(f.icon), act: () => { void Promise.resolve(f.run()).catch((e) => flash(String(e), true)); } })),
|
||||
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
||||
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
|
||||
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
|
||||
@ -1693,7 +1702,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
</div>
|
||||
|
||||
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
|
||||
height={dockH} onResize={startDockResize}
|
||||
height={dockH} onResize={startDockResize} docks={getRegistry().docks}
|
||||
logs={logs} onClearLogs={() => setLogs([])}
|
||||
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
|
||||
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}
|
||||
@ -2059,18 +2068,20 @@ function LogRow({ l }: { l: LogEntry }) {
|
||||
// common p4 subcommands for the terminal's autocomplete
|
||||
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
|
||||
|
||||
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
|
||||
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
|
||||
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
|
||||
height: number; onResize: (e: ReactMouseEvent) => void;
|
||||
/* ---------------- bottom dock: tabbed Log / Terminal + plugin dock panels ---------------- */
|
||||
function DockPanel({ tab, setTab, onClose, height, onResize, docks, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
|
||||
tab: string; setTab: (t: string) => void; onClose: () => void;
|
||||
height: number; onResize: (e: ReactMouseEvent) => void; docks: PluginDock[];
|
||||
logs: LogEntry[]; onClearLogs: () => void;
|
||||
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
|
||||
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
|
||||
}) {
|
||||
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
|
||||
const tabs: { key: string; label: string; icon: ReactNode }[] = [
|
||||
{ key: "log", label: t("Log"), icon: I.log },
|
||||
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
|
||||
...docks.map((d) => ({ key: `plugin:${d.id}`, label: d.title, icon: pluginIcon(d.icon) })),
|
||||
];
|
||||
const activeDock = tab.startsWith("plugin:") ? docks.find((d) => `plugin:${d.id}` === tab) : undefined;
|
||||
return (
|
||||
<div className="dockpanel" style={{ flexBasis: height, height }}>
|
||||
<div className="dockhandle" onMouseDown={onResize} title={t("Drag to resize")} />
|
||||
@ -2090,11 +2101,26 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
|
||||
<div className="dockpane" key={tab}>
|
||||
{tab === "log" && <LogTab logs={logs} />}
|
||||
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
|
||||
{activeDock && <PluginDockPanel dock={activeDock} />}
|
||||
{tab.startsWith("plugin:") && !activeDock && <div className="logempty">{t("This panel's plugin is no longer active.")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Host for a plugin-provided dock panel: mounts the plugin's render() into a div
|
||||
// and runs its cleanup on unmount / tab switch. Same pattern as PluginViewModal.
|
||||
function PluginDockPanel({ dock }: { dock: PluginDock }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current; if (!el) return;
|
||||
let cleanup: void | (() => void);
|
||||
try { cleanup = dock.render(el); } catch (e) { el.textContent = String(e); }
|
||||
return () => { try { (cleanup as (() => void) | undefined)?.(); } catch { /* ignore */ } el.innerHTML = ""; };
|
||||
}, [dock]);
|
||||
return <div className="dockplugin" ref={ref} style={{ height: "100%", overflow: "auto" }} />;
|
||||
}
|
||||
|
||||
function LogTab({ logs }: { logs: LogEntry[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
|
||||
|
||||
@ -11,14 +11,18 @@ import { t } from "./i18n";
|
||||
import { resolveTheme, loadActiveId } from "./themes";
|
||||
|
||||
// ---- contribution shapes ----------------------------------------------------
|
||||
export interface PluginMenuItem { pluginId: string; label: string; run: () => void | Promise<void> }
|
||||
export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
|
||||
// `icon` is a NAME from the host icon set (e.g. "ue", "hammer"); the host renders
|
||||
// the real SVG. Unknown / omitted names fall back to a neutral glyph.
|
||||
export interface PluginMenuItem { pluginId: string; label: string; icon?: string; run: () => void | Promise<void> }
|
||||
export interface PluginFileItem { pluginId: string; label: string; icon?: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
|
||||
export interface PluginFolderItem { pluginId: string; label: string; icon?: string; visible?: () => boolean; run: () => void | Promise<void> }
|
||||
export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginDock { pluginId: string; id: string; title: string; icon?: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict> }
|
||||
export type SubmitVerdict = { ok: boolean; message?: string };
|
||||
|
||||
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; views: PluginView[]; submitHooks: PluginSubmitHook[] }
|
||||
const registry: Registry = { menu: [], fileItems: [], views: [], submitHooks: [] };
|
||||
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; folderItems: PluginFolderItem[]; views: PluginView[]; docks: PluginDock[]; submitHooks: PluginSubmitHook[] }
|
||||
const registry: Registry = { menu: [], fileItems: [], folderItems: [], views: [], docks: [], submitHooks: [] };
|
||||
let version = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
function bump() { version++; listeners.forEach((l) => l()); }
|
||||
@ -26,7 +30,7 @@ function bump() { version++; listeners.forEach((l) => l()); }
|
||||
export function getRegistry(): Registry { return registry; }
|
||||
export function registryVersion(): number { return version; }
|
||||
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
|
||||
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.views = []; registry.submitHooks = []; }
|
||||
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.folderItems = []; registry.views = []; registry.docks = []; registry.submitHooks = []; }
|
||||
|
||||
// Unreal Engine bridge — the core keeps the build machinery + overlay + log
|
||||
// reader; the plugin only drives them. Gated by the "ue" permission.
|
||||
@ -34,6 +38,7 @@ export interface UeBridge {
|
||||
hasProject: () => boolean;
|
||||
uproject: () => string;
|
||||
build: () => void;
|
||||
launch: () => void;
|
||||
readLog: (tailBytes?: number) => Promise<string>;
|
||||
}
|
||||
|
||||
@ -55,9 +60,11 @@ export interface HostApi {
|
||||
has: (perm: string) => boolean;
|
||||
p4: Record<string, (...a: unknown[]) => Promise<unknown>>;
|
||||
ui: {
|
||||
addMenuItem: (label: string, run: () => void | Promise<void>) => void;
|
||||
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>) => void;
|
||||
addMenuItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFolderContextItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string; visible?: () => boolean }) => void;
|
||||
addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addDockPanel: (dock: { id: string; title: string; icon?: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict>) => void;
|
||||
};
|
||||
flash: (text: string, err?: boolean) => void;
|
||||
@ -95,9 +102,11 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
|
||||
has,
|
||||
p4: p4host,
|
||||
ui: {
|
||||
addMenuItem: (label, run) => { registry.menu.push({ pluginId: info.id, label, run }); bump(); },
|
||||
addFileContextItem: (label, run) => { registry.fileItems.push({ pluginId: info.id, label, run }); bump(); },
|
||||
addMenuItem: (label, run, opts) => { registry.menu.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFileContextItem: (label, run, opts) => { registry.fileItems.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFolderContextItem: (label, run, opts) => { registry.folderItems.push({ pluginId: info.id, label, icon: opts?.icon, visible: opts?.visible, run }); bump(); },
|
||||
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); },
|
||||
addDockPanel: (dock) => { registry.docks.push({ pluginId: info.id, id: dock.id, title: dock.title, icon: dock.icon, render: (el) => dock.render(el, host) }); bump(); },
|
||||
addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); },
|
||||
},
|
||||
flash: ctx.flash,
|
||||
|
||||
Reference in New Issue
Block a user