diff --git a/package.json b/package.json index c477768..2f63786 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.6", + "version": "0.3.7", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins-examples/unreal/index.js b/plugins-examples/unreal/index.js index db9f4fc..e40b1d7 100644 --- a/plugins-examples/unreal/index.js +++ b/plugins-examples/unreal/index.js @@ -1,28 +1,32 @@ // Unreal Engine Tools — official plugin. // The core app keeps the build engine (UnrealBuildTool / MSBuild), the build -// overlay and the log reader; this plugin is just the Unreal-facing surface: -// a "Build Solution" action and a live Unreal-log view. Reached through the -// permission-gated `host.ue` bridge. +// overlay and the log reader; this plugin is just the Unreal-facing surface. +// It renders natively: the actions sit in the working-folder right-click menu +// with the host's Unreal + hammer icons, and the log is a bottom-dock tab — +// exactly where these lived before, just delivered as a plugin. Reached through +// the permission-gated `host.ue` bridge. export function activate(host) { const ue = host.ue; if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; } - // Build the game target (UnrealBuildTool for the game module, MSBuild for plain C++). - host.ui.addMenuItem("🔨 Build Solution (Unreal)", () => { - if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } - ue.build(); // triggers the core build flow + overlay - }); + // Right-click the working folder → Launch / Build, shown only on a UE project. + // Prefer the native folder-context slot (host ≥ 0.3.7); fall back to the top + // menu on older hosts so the actions are always reachable. + const addAction = (label, run, icon) => { + if (host.ui.addFolderContextItem) host.ui.addFolderContextItem(label, run, { icon, visible: () => ue.hasProject() }); + else host.ui.addMenuItem(label, () => { if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } run(); }, { icon }); + }; + addAction("Launch Unreal Engine", () => ue.launch(), "ue"); + addAction("Build Solution (.sln)", () => ue.build(), "hammer"); - // Live Unreal editor log (tails Saved/Logs). Opens from the Actions menu. - host.ui.addView({ - id: "unreal-log", - title: "Unreal Log", - render: (el) => { - el.style.cssText = "display:flex;flex-direction:column;height:100%;min-height:340px"; + // Bottom-dock "Unreal" tab — tails the editor log (Saved/Logs), refreshing live. + // Native dock panel on host ≥ 0.3.7; a modal view as a fallback on older hosts. + const mountLog = (el) => { + el.style.cssText = "display:flex;flex-direction:column;height:100%"; const pre = document.createElement("pre"); pre.style.cssText = - "flex:1;margin:0;overflow:auto;white-space:pre-wrap;word-break:break-word;" + + "flex:1;margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap;word-break:break-word;" + "font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)"; el.appendChild(pre); let alive = true; @@ -32,15 +36,16 @@ export function activate(host) { if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; } const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30; const txt = await ue.readLog(200000); - pre.textContent = txt || "No Unreal log yet. It appears when the editor writes to Saved/Logs (i.e. while Unreal is running)."; + pre.textContent = txt || "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running."; if (atBottom) pre.scrollTop = pre.scrollHeight; } catch (e) { pre.textContent = String(e); } } tick(); const id = setInterval(tick, 1500); return () => { alive = false; clearInterval(id); }; - }, - }); + }; + if (host.ui.addDockPanel) host.ui.addDockPanel({ id: "unreal", title: "Unreal", icon: "ue", render: mountLog }); + else host.ui.addView({ id: "unreal-log", title: "Unreal Log", render: mountLog }); host.log("Unreal Engine Tools activated"); } diff --git a/plugins-examples/unreal/plugin.json b/plugins-examples/unreal/plugin.json index dc19d51..58ad172 100644 --- a/plugins-examples/unreal/plugin.json +++ b/plugins-examples/unreal/plugin.json @@ -1,12 +1,12 @@ { "id": "unreal", "name": "Unreal Engine Tools", - "version": "1.0.0", + "version": "1.2.0", "author": "Exbyte Studios", - "description": "Build the game (.sln / UnrealBuildTool) and tail the Unreal editor log. Enable this on Unreal Engine projects.", + "description": "Build the game (.sln / UnrealBuildTool), launch the editor, and tail the Unreal log. Actions live in the working-folder right-click menu; the log is a bottom-dock tab. Enable on Unreal Engine projects.", "entry": "index.js", "minAppVersion": "0.3.6", "permissions": ["ue"], "defaultEnabled": true, - "contributes": { "menu": true, "views": ["unreal-log"] } + "contributes": { "folderContext": true, "dock": ["unreal"] } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index caa3df4..5ffc70d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.3.6" +version = "0.3.7" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c7a10a6..d44b7a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.3.6" +version = "0.3.7" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8933e81..e3dea56 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.3.6", + "version": "0.3.7", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index f0569d7..bd3e335 100644 --- a/src/App.tsx +++ b/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: , }; +// 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)[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); // right-click menu const [logs, setLogs] = useState([]); // 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("log"); // right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab. const [viewTabs, setViewTabs] = useState(() => { 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 {dockOpen && 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 (
@@ -2090,11 +2101,26 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
{tab === "log" && } {tab === "terminal" && } + {activeDock && } + {tab.startsWith("plugin:") && !activeDock &&
{t("This panel's plugin is no longer active.")}
}
); } +// 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(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
; +} + function LogTab({ logs }: { logs: LogEntry[] }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]); diff --git a/src/plugins.ts b/src/plugins.ts index 48af009..03d91ed 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -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 } -export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise } +// `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 } +export interface PluginFileItem { pluginId: string; label: string; icon?: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise } +export interface PluginFolderItem { pluginId: string; label: string; icon?: string; visible?: () => boolean; run: () => void | Promise } 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 } 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; } @@ -55,9 +60,11 @@ export interface HostApi { has: (perm: string) => boolean; p4: Record Promise>; ui: { - addMenuItem: (label: string, run: () => void | Promise) => void; - addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise) => void; + addMenuItem: (label: string, run: () => void | Promise, opts?: { icon?: string }) => void; + addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise, opts?: { icon?: string }) => void; + addFolderContextItem: (label: string, run: () => void | Promise, 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) => 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,