From 621dc4c27d679b75e259097520d56000f66e00da Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Wed, 25 Mar 2026 03:47:53 +0000 Subject: [PATCH] Add asset browser bottom panel and default layout Asset browser widget with grid view for navigating merged asset layers (mod > dx > us), breadcrumb navigation, eye toggle for base game asset visibility, and file opening via double-click. Default layout places Outline on left, Debug and Problems on right. All panels are non-closable. View > Editor Layout > Default resets layout. --- .claude/settings.json | 3 + extensions/studio/package.json | 4 +- .../src/browser/asset-browser/contribution.ts | 31 ++++ .../src/browser/asset-browser/service.ts | 80 +++++++++ .../src/browser/asset-browser/style.css | 89 ++++++++++ .../src/browser/asset-browser/widget.tsx | 157 ++++++++++++++++++ .../studio/src/browser/layout-contribution.ts | 60 +++++++ .../src/browser/studio-frontend-module.ts | 32 +++- 8 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 extensions/studio/src/browser/asset-browser/contribution.ts create mode 100644 extensions/studio/src/browser/asset-browser/service.ts create mode 100644 extensions/studio/src/browser/asset-browser/style.css create mode 100644 extensions/studio/src/browser/asset-browser/widget.tsx create mode 100644 extensions/studio/src/browser/layout-contribution.ts diff --git a/.claude/settings.json b/.claude/settings.json index e8b01f3..38508b7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,5 +12,8 @@ ] } ] + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true } } diff --git a/extensions/studio/package.json b/extensions/studio/package.json index 4ad6ef3..96a1308 100644 --- a/extensions/studio/package.json +++ b/extensions/studio/package.json @@ -6,7 +6,9 @@ "theia-extension" ], "dependencies": { - "@theia/core": "1.69.0" + "@theia/core": "1.69.0", + "@theia/filesystem": "1.69.0", + "@theia/workspace": "1.69.0" }, "devDependencies": { "typescript": "~5.4.0", diff --git a/extensions/studio/src/browser/asset-browser/contribution.ts b/extensions/studio/src/browser/asset-browser/contribution.ts new file mode 100644 index 0000000..0761028 --- /dev/null +++ b/extensions/studio/src/browser/asset-browser/contribution.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable } from "@theia/core/shared/inversify"; +import { AbstractViewContribution } from "@theia/core/lib/browser/shell/view-contribution"; +import { + FrontendApplication, + FrontendApplicationContribution, +} from "@theia/core/lib/browser"; +import { AssetBrowserWidget, ASSET_BROWSER_WIDGET_ID } from "./widget"; + +@injectable() +export class AssetBrowserContribution + extends AbstractViewContribution + implements FrontendApplicationContribution +{ + constructor() { + super({ + widgetId: ASSET_BROWSER_WIDGET_ID, + widgetName: "Assets", + defaultWidgetOptions: { + area: "bottom", + }, + }); + } + + async initializeLayout(_app: FrontendApplication): Promise { + await this.openView({ activate: true }); + } +} diff --git a/extensions/studio/src/browser/asset-browser/service.ts b/extensions/studio/src/browser/asset-browser/service.ts new file mode 100644 index 0000000..7ffb37e --- /dev/null +++ b/extensions/studio/src/browser/asset-browser/service.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, inject } from "@theia/core/shared/inversify"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import { WorkspaceService } from "@theia/workspace/lib/browser"; +import URI from "@theia/core/lib/common/uri"; + +export type AssetSource = "mod" | "dx" | "us"; + +export interface AssetEntry { + name: string; + uri: URI; + isDirectory: boolean; + source: AssetSource; +} + +const ASSET_LAYERS: { dir: string; source: AssetSource }[] = [ + { dir: "assets/mod", source: "mod" }, + { dir: "assets/dx", source: "dx" }, + { dir: "assets/us", source: "us" }, +]; + +@injectable() +export class AssetService { + @inject(FileService) + protected readonly fileService!: FileService; + + @inject(WorkspaceService) + protected readonly workspaceService!: WorkspaceService; + + async listDirectory(relativePath: string): Promise { + const roots = this.workspaceService.tryGetRoots(); + if (roots.length === 0) { + return []; + } + const workspaceRoot = new URI(roots[0].resource.toString()); + + const merged = new Map(); + + // Iterate layers from lowest to highest priority so higher-priority layers overwrite + for (let i = ASSET_LAYERS.length - 1; i >= 0; i--) { + const layer = ASSET_LAYERS[i]; + const dirUri = workspaceRoot.resolve(layer.dir).resolve(relativePath); + + try { + const stat = await this.fileService.resolve(dirUri, { + resolveMetadata: false, + }); + if (stat.children) { + for (const child of stat.children) { + const name = new URI(child.resource.toString()).path.base; + merged.set(name, { + name, + uri: new URI(child.resource.toString()), + isDirectory: child.isDirectory, + source: layer.source, + }); + } + } + } catch { + // Layer directory doesn't exist — skip + } + } + + const entries = Array.from(merged.values()); + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + return entries; + } + + isOwned(entry: AssetEntry): boolean { + return entry.source === "mod"; + } +} diff --git a/extensions/studio/src/browser/asset-browser/style.css b/extensions/studio/src/browser/asset-browser/style.css new file mode 100644 index 0000000..cd859b3 --- /dev/null +++ b/extensions/studio/src/browser/asset-browser/style.css @@ -0,0 +1,89 @@ +/* SPDX-FileCopyrightText: 2026 Star Haven contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.asset-browser-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.asset-browser-breadcrumb { + display: flex; + align-items: center; + padding: 4px 8px; + gap: 4px; + border-bottom: 1px solid var(--theia-panel-border); + flex-shrink: 0; +} + +.asset-browser-breadcrumb-segment { + cursor: pointer; +} + +.asset-browser-breadcrumb-segment:hover { + text-decoration: underline; +} + +.asset-browser-breadcrumb-separator { + opacity: 0.5; +} + +.asset-browser-spacer { + flex: 1; +} + +.asset-browser-toggle { + cursor: pointer; + font-size: 16px; + padding: 2px 4px; + border-radius: 4px; +} + +.asset-browser-toggle:hover { + background: var(--theia-list-hoverBackground); +} + +.asset-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 4px; + padding: 4px; + overflow-y: auto; + flex: 1; +} + +.asset-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 4px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.asset-item:hover { + background: var(--theia-list-hoverBackground); +} + +.asset-item.selected { + background: var(--theia-list-activeSelectionBackground); +} + +.asset-item.base { + opacity: 0.7; +} + +.asset-item-icon { + font-size: 32px; + margin-bottom: 4px; +} + +.asset-item-label { + font-size: 11px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} diff --git a/extensions/studio/src/browser/asset-browser/widget.tsx b/extensions/studio/src/browser/asset-browser/widget.tsx new file mode 100644 index 0000000..bdcf397 --- /dev/null +++ b/extensions/studio/src/browser/asset-browser/widget.tsx @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as React from "react"; +import { + injectable, + inject, + postConstruct, +} from "@theia/core/shared/inversify"; +import { ReactWidget } from "@theia/core/lib/browser/widgets/react-widget"; +import { OpenerService, open } from "@theia/core/lib/browser/opener-service"; +import { AssetService, AssetEntry } from "./service"; + +export const ASSET_BROWSER_WIDGET_ID = "asset-browser"; + +@injectable() +export class AssetBrowserWidget extends ReactWidget { + static readonly ID = ASSET_BROWSER_WIDGET_ID; + static readonly LABEL = "Assets"; + + @inject(AssetService) + protected readonly assetService!: AssetService; + + @inject(OpenerService) + protected readonly openerService!: OpenerService; + + protected currentPath = ""; + protected showBaseAssets = true; + protected entries: AssetEntry[] = []; + protected selected: string | null = null; + + @postConstruct() + protected init(): void { + this.id = AssetBrowserWidget.ID; + this.title.label = AssetBrowserWidget.LABEL; + this.title.caption = AssetBrowserWidget.LABEL; + this.title.closable = false; + this.title.iconClass = "codicon codicon-folder-opened"; + this.addClass("asset-browser"); + this.refresh(); + } + + protected async refresh(): Promise { + this.entries = await this.assetService.listDirectory(this.currentPath); + this.update(); + } + + protected navigateTo(path: string): void { + this.currentPath = path; + this.selected = null; + this.refresh(); + } + + protected render(): React.ReactNode { + return ( +
+ {this.renderBreadcrumb()} + {this.renderGrid()} +
+ ); + } + + protected renderBreadcrumb(): React.ReactNode { + const segments = this.currentPath.split("/").filter((s) => s.length > 0); + + return ( +
+ this.navigateTo("")} + > + assets + + {segments.map((seg, i) => { + const path = segments.slice(0, i + 1).join("/") + "/"; + return ( + + / + this.navigateTo(path)} + > + {seg} + + + ); + })} + + { + this.showBaseAssets = !this.showBaseAssets; + this.update(); + }} + /> +
+ ); + } + + protected renderGrid(): React.ReactNode { + const visibleEntries = this.entries.filter( + (e) => this.showBaseAssets || this.assetService.isOwned(e), + ); + + return ( +
+ {visibleEntries.map((entry) => this.renderItem(entry))} +
+ ); + } + + protected renderItem(entry: AssetEntry): React.ReactNode { + const isBase = !this.assetService.isOwned(entry); + const isSelected = this.selected === entry.name; + const classes = [ + "asset-item", + isSelected ? "selected" : "", + isBase && this.showBaseAssets ? "base" : "", + ] + .filter(Boolean) + .join(" "); + + const iconClass = entry.isDirectory + ? "codicon codicon-folder" + : "codicon codicon-file"; + + return ( +
{ + this.selected = entry.name; + this.update(); + }} + onDoubleClick={() => { + if (entry.isDirectory) { + this.navigateTo(this.currentPath + entry.name + "/"); + } else { + open(this.openerService, entry.uri); + } + }} + > + + {entry.name} +
+ ); + } +} diff --git a/extensions/studio/src/browser/layout-contribution.ts b/extensions/studio/src/browser/layout-contribution.ts new file mode 100644 index 0000000..ca6fd3a --- /dev/null +++ b/extensions/studio/src/browser/layout-contribution.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, inject } from "@theia/core/shared/inversify"; +import { + FrontendApplication, + FrontendApplicationContribution, +} from "@theia/core/lib/browser"; +import { + MenuContribution, + MenuModelRegistry, + MenuPath, +} from "@theia/core/lib/common"; +import { CommonMenus } from "@theia/core/lib/browser"; +import { RESET_LAYOUT } from "@theia/core/lib/browser/shell/shell-layout-restorer"; +import { WidgetManager } from "@theia/core/lib/browser/widget-manager"; + +const NON_CLOSABLE_WIDGETS = new Set(["outline-view", "debug", "problems"]); + +const EDITOR_LAYOUT_SUBMENU: MenuPath = [ + ...CommonMenus.VIEW_LAYOUT, + "editor_layout", +]; + +@injectable() +export class LayoutContribution + implements FrontendApplicationContribution, MenuContribution +{ + @inject(WidgetManager) + protected readonly widgetManager!: WidgetManager; + + onStart(): void { + this.widgetManager.onDidCreateWidget(({ widget }) => { + if (NON_CLOSABLE_WIDGETS.has(widget.id)) { + widget.title.closable = false; + } + }); + } + + async initializeLayout(app: FrontendApplication): Promise { + const outline = await this.widgetManager.getOrCreateWidget("outline-view"); + app.shell.addWidget(outline, { area: "left" }); + app.shell.activateWidget(outline.id); + + const debug = await this.widgetManager.getOrCreateWidget("debug"); + app.shell.addWidget(debug, { area: "right" }); + + const problems = await this.widgetManager.getOrCreateWidget("problems"); + app.shell.addWidget(problems, { area: "right" }); + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerSubmenu(EDITOR_LAYOUT_SUBMENU, "Editor Layout"); + menus.registerMenuAction(EDITOR_LAYOUT_SUBMENU, { + commandId: RESET_LAYOUT.id, + label: "Default", + }); + } +} diff --git a/extensions/studio/src/browser/studio-frontend-module.ts b/extensions/studio/src/browser/studio-frontend-module.ts index 4187da2..10d08af 100644 --- a/extensions/studio/src/browser/studio-frontend-module.ts +++ b/extensions/studio/src/browser/studio-frontend-module.ts @@ -3,7 +3,37 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { ContainerModule } from "@theia/core/shared/inversify"; +import { + bindViewContribution, + FrontendApplicationContribution, + WidgetFactory, +} from "@theia/core/lib/browser"; +import { MenuContribution } from "@theia/core/lib/common"; +import { AssetService } from "./asset-browser/service"; +import { + AssetBrowserWidget, + ASSET_BROWSER_WIDGET_ID, +} from "./asset-browser/widget"; +import { AssetBrowserContribution } from "./asset-browser/contribution"; +import { LayoutContribution } from "./layout-contribution"; + +import "../../src/browser/asset-browser/style.css"; export default new ContainerModule((bind) => { - // Studio extension bindings go here + bind(AssetService).toSelf().inSingletonScope(); + + bindViewContribution(bind, AssetBrowserContribution); + bind(FrontendApplicationContribution).toService(AssetBrowserContribution); + + bind(AssetBrowserWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(({ container }) => ({ + id: ASSET_BROWSER_WIDGET_ID, + createWidget: () => container.get(AssetBrowserWidget), + })) + .inSingletonScope(); + + bind(LayoutContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(LayoutContribution); + bind(MenuContribution).toService(LayoutContribution); }); -- 2.51.2