- CURSOR_MARKER: \x1b_pi:c\x07 → \x1b_sf:c\x07 - process.title: "pi" → "sf" - PiManifest → SFManifest (with pi field backwards compat) - readPiManifest → readSFManifest (loader.ts and package-manager.ts) - readPiManifestFile → readSFManifestFile (package-manager.ts) - .pi/skills → .sf/skills (keeps .pi/skills for backwards compat) - User-facing path strings updated to .sf/ where appropriate - ARCHITECTURE.md: "Pi coding-agent extension" → "coding-agent extension" - Temp editor file: pi-editor-*.pi.md → sf-editor-*.sf.md - Test fixtures: appName "pi" → "sf", pi manifest field → sf Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
673 lines
18 KiB
TypeScript
673 lines
18 KiB
TypeScript
/**
|
|
* TUI component for managing package resources (enable/disable)
|
|
*/
|
|
|
|
import { basename, dirname, join, relative } from "node:path";
|
|
import {
|
|
type Component,
|
|
Container,
|
|
type Focusable,
|
|
getEditorKeybindings,
|
|
Input,
|
|
matchesKey,
|
|
Spacer,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "@singularity-forge/tui";
|
|
import { CONFIG_DIR_NAME } from "../../../config.js";
|
|
import type {
|
|
PathMetadata,
|
|
ResolvedPaths,
|
|
ResolvedResource,
|
|
} from "../../../core/package-manager.js";
|
|
import type {
|
|
PackageSource,
|
|
SettingsManager,
|
|
} from "../../../core/settings-manager.js";
|
|
import { theme } from "../theme/theme.js";
|
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
import { rawKeyHint } from "./keybinding-hints.js";
|
|
|
|
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
|
|
|
|
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
|
extensions: "Extensions",
|
|
skills: "Skills",
|
|
prompts: "Prompts",
|
|
themes: "Themes",
|
|
};
|
|
|
|
interface ResourceItem {
|
|
path: string;
|
|
enabled: boolean;
|
|
metadata: PathMetadata;
|
|
resourceType: ResourceType;
|
|
displayName: string;
|
|
groupKey: string;
|
|
subgroupKey: string;
|
|
}
|
|
|
|
interface ResourceSubgroup {
|
|
type: ResourceType;
|
|
label: string;
|
|
items: ResourceItem[];
|
|
}
|
|
|
|
interface ResourceGroup {
|
|
key: string;
|
|
label: string;
|
|
scope: "user" | "project" | "temporary";
|
|
origin: "package" | "top-level";
|
|
source: string;
|
|
subgroups: ResourceSubgroup[];
|
|
}
|
|
|
|
function getGroupLabel(metadata: PathMetadata): string {
|
|
if (metadata.origin === "package") {
|
|
return `${metadata.source} (${metadata.scope})`;
|
|
}
|
|
// Top-level resources
|
|
if (metadata.source === "auto") {
|
|
return metadata.scope === "user" ? "User (~/.sf/agent/)" : "Project (.sf/)";
|
|
}
|
|
return metadata.scope === "user" ? "User settings" : "Project settings";
|
|
}
|
|
|
|
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
|
|
const groupMap = new Map<string, ResourceGroup>();
|
|
|
|
const addToGroup = (
|
|
resources: ResolvedResource[],
|
|
resourceType: ResourceType,
|
|
) => {
|
|
for (const res of resources) {
|
|
const { path, enabled, metadata } = res;
|
|
const groupKey = `${metadata.origin}:${metadata.scope}:${metadata.source}`;
|
|
|
|
if (!groupMap.has(groupKey)) {
|
|
groupMap.set(groupKey, {
|
|
key: groupKey,
|
|
label: getGroupLabel(metadata),
|
|
scope: metadata.scope,
|
|
origin: metadata.origin,
|
|
source: metadata.source,
|
|
subgroups: [],
|
|
});
|
|
}
|
|
|
|
const group = groupMap.get(groupKey)!;
|
|
const subgroupKey = `${groupKey}:${resourceType}`;
|
|
|
|
let subgroup = group.subgroups.find((sg) => sg.type === resourceType);
|
|
if (!subgroup) {
|
|
subgroup = {
|
|
type: resourceType,
|
|
label: RESOURCE_TYPE_LABELS[resourceType],
|
|
items: [],
|
|
};
|
|
group.subgroups.push(subgroup);
|
|
}
|
|
|
|
const fileName = basename(path);
|
|
const parentFolder = basename(dirname(path));
|
|
let displayName: string;
|
|
if (resourceType === "extensions" && parentFolder !== "extensions") {
|
|
displayName = `${parentFolder}/${fileName}`;
|
|
} else if (resourceType === "skills" && fileName === "SKILL.md") {
|
|
displayName = parentFolder;
|
|
} else {
|
|
displayName = fileName;
|
|
}
|
|
subgroup.items.push({
|
|
path,
|
|
enabled,
|
|
metadata,
|
|
resourceType,
|
|
displayName,
|
|
groupKey,
|
|
subgroupKey,
|
|
});
|
|
}
|
|
};
|
|
|
|
addToGroup(resolved.extensions, "extensions");
|
|
addToGroup(resolved.skills, "skills");
|
|
addToGroup(resolved.prompts, "prompts");
|
|
addToGroup(resolved.themes, "themes");
|
|
|
|
// Sort groups: packages first, then top-level; user before project
|
|
const groups = Array.from(groupMap.values());
|
|
groups.sort((a, b) => {
|
|
if (a.origin !== b.origin) {
|
|
return a.origin === "package" ? -1 : 1;
|
|
}
|
|
if (a.scope !== b.scope) {
|
|
return a.scope === "user" ? -1 : 1;
|
|
}
|
|
return a.source.localeCompare(b.source);
|
|
});
|
|
|
|
// Sort subgroups within each group by type order, and items by name
|
|
const typeOrder: Record<ResourceType, number> = {
|
|
extensions: 0,
|
|
skills: 1,
|
|
prompts: 2,
|
|
themes: 3,
|
|
};
|
|
for (const group of groups) {
|
|
group.subgroups.sort((a, b) => typeOrder[a.type] - typeOrder[b.type]);
|
|
for (const subgroup of group.subgroups) {
|
|
subgroup.items.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
type FlatEntry =
|
|
| { type: "group"; group: ResourceGroup }
|
|
| { type: "subgroup"; subgroup: ResourceSubgroup; group: ResourceGroup }
|
|
| { type: "item"; item: ResourceItem };
|
|
|
|
class ConfigSelectorHeader implements Component {
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
const title = theme.bold("Resource Configuration");
|
|
const sep = theme.fg("muted", " · ");
|
|
const hint =
|
|
rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close");
|
|
const hintWidth = visibleWidth(hint);
|
|
const titleWidth = visibleWidth(title);
|
|
const spacing = Math.max(1, width - titleWidth - hintWidth);
|
|
|
|
return [
|
|
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
|
theme.fg("muted", "Type to filter resources"),
|
|
];
|
|
}
|
|
}
|
|
|
|
class ResourceList implements Component, Focusable {
|
|
private groups: ResourceGroup[];
|
|
private flatItems: FlatEntry[] = [];
|
|
private filteredItems: FlatEntry[] = [];
|
|
private selectedIndex = 0;
|
|
private searchInput: Input;
|
|
private maxVisible = 15;
|
|
private settingsManager: SettingsManager;
|
|
private cwd: string;
|
|
private agentDir: string;
|
|
|
|
public onCancel?: () => void;
|
|
public onExit?: () => void;
|
|
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
|
|
|
|
private _focused = false;
|
|
get focused(): boolean {
|
|
return this._focused;
|
|
}
|
|
set focused(value: boolean) {
|
|
this._focused = value;
|
|
this.searchInput.focused = value;
|
|
}
|
|
|
|
constructor(
|
|
groups: ResourceGroup[],
|
|
settingsManager: SettingsManager,
|
|
cwd: string,
|
|
agentDir: string,
|
|
) {
|
|
this.groups = groups;
|
|
this.settingsManager = settingsManager;
|
|
this.cwd = cwd;
|
|
this.agentDir = agentDir;
|
|
this.searchInput = new Input();
|
|
this.buildFlatList();
|
|
this.filteredItems = [...this.flatItems];
|
|
}
|
|
|
|
private buildFlatList(): void {
|
|
this.flatItems = [];
|
|
for (const group of this.groups) {
|
|
this.flatItems.push({ type: "group", group });
|
|
for (const subgroup of group.subgroups) {
|
|
this.flatItems.push({ type: "subgroup", subgroup, group });
|
|
for (const item of subgroup.items) {
|
|
this.flatItems.push({ type: "item", item });
|
|
}
|
|
}
|
|
}
|
|
// Start selection on first item (not header)
|
|
this.selectedIndex = this.flatItems.findIndex((e) => e.type === "item");
|
|
if (this.selectedIndex < 0) this.selectedIndex = 0;
|
|
}
|
|
|
|
private findNextItem(fromIndex: number, direction: 1 | -1): number {
|
|
let idx = fromIndex + direction;
|
|
while (idx >= 0 && idx < this.filteredItems.length) {
|
|
if (this.filteredItems[idx].type === "item") {
|
|
return idx;
|
|
}
|
|
idx += direction;
|
|
}
|
|
return fromIndex; // Stay at current if no item found
|
|
}
|
|
|
|
private filterItems(query: string): void {
|
|
if (!query.trim()) {
|
|
this.filteredItems = [...this.flatItems];
|
|
this.selectFirstItem();
|
|
return;
|
|
}
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
const matchingItems = new Set<ResourceItem>();
|
|
const matchingSubgroups = new Set<ResourceSubgroup>();
|
|
const matchingGroups = new Set<ResourceGroup>();
|
|
|
|
for (const entry of this.flatItems) {
|
|
if (entry.type === "item") {
|
|
const item = entry.item;
|
|
if (
|
|
item.displayName.toLowerCase().includes(lowerQuery) ||
|
|
item.resourceType.toLowerCase().includes(lowerQuery) ||
|
|
item.path.toLowerCase().includes(lowerQuery)
|
|
) {
|
|
matchingItems.add(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find which subgroups and groups contain matching items
|
|
for (const group of this.groups) {
|
|
for (const subgroup of group.subgroups) {
|
|
for (const item of subgroup.items) {
|
|
if (matchingItems.has(item)) {
|
|
matchingSubgroups.add(subgroup);
|
|
matchingGroups.add(group);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.filteredItems = [];
|
|
for (const entry of this.flatItems) {
|
|
if (entry.type === "group" && matchingGroups.has(entry.group)) {
|
|
this.filteredItems.push(entry);
|
|
} else if (
|
|
entry.type === "subgroup" &&
|
|
matchingSubgroups.has(entry.subgroup)
|
|
) {
|
|
this.filteredItems.push(entry);
|
|
} else if (entry.type === "item" && matchingItems.has(entry.item)) {
|
|
this.filteredItems.push(entry);
|
|
}
|
|
}
|
|
|
|
this.selectFirstItem();
|
|
}
|
|
|
|
private selectFirstItem(): void {
|
|
const firstItemIndex = this.filteredItems.findIndex(
|
|
(e) => e.type === "item",
|
|
);
|
|
this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0;
|
|
}
|
|
|
|
updateItem(item: ResourceItem, enabled: boolean): void {
|
|
item.enabled = enabled;
|
|
// Update in groups too
|
|
for (const group of this.groups) {
|
|
for (const subgroup of group.subgroups) {
|
|
const found = subgroup.items.find(
|
|
(i) => i.path === item.path && i.resourceType === item.resourceType,
|
|
);
|
|
if (found) {
|
|
found.enabled = enabled;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
// Search input
|
|
lines.push(...this.searchInput.render(width));
|
|
lines.push("");
|
|
|
|
if (this.filteredItems.length === 0) {
|
|
lines.push(theme.fg("muted", " No resources found"));
|
|
return lines;
|
|
}
|
|
|
|
// Calculate visible range
|
|
const startIndex = Math.max(
|
|
0,
|
|
Math.min(
|
|
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
this.filteredItems.length - this.maxVisible,
|
|
),
|
|
);
|
|
const endIndex = Math.min(
|
|
startIndex + this.maxVisible,
|
|
this.filteredItems.length,
|
|
);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const entry = this.filteredItems[i];
|
|
const isSelected = i === this.selectedIndex;
|
|
|
|
if (entry.type === "group") {
|
|
// Main group header (no cursor)
|
|
const groupLine = theme.fg("accent", theme.bold(entry.group.label));
|
|
lines.push(truncateToWidth(` ${groupLine}`, width, ""));
|
|
} else if (entry.type === "subgroup") {
|
|
// Subgroup header (indented, no cursor)
|
|
const subgroupLine = theme.fg("muted", entry.subgroup.label);
|
|
lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
|
|
} else {
|
|
// Resource item (cursor only on items)
|
|
const item = entry.item;
|
|
const cursor = isSelected ? "> " : " ";
|
|
const checkbox = item.enabled
|
|
? theme.fg("success", "[x]")
|
|
: theme.fg("dim", "[ ]");
|
|
const name = isSelected
|
|
? theme.bold(item.displayName)
|
|
: item.displayName;
|
|
lines.push(
|
|
truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "..."),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Scroll indicator — count only selectable items (exclude group/subgroup headers)
|
|
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
const selectableItems = this.filteredItems.filter(
|
|
(e) => e.type === "item",
|
|
);
|
|
const selectableTotal = selectableItems.length;
|
|
const selectablePosition = selectableItems.findIndex(
|
|
(e) => this.filteredItems.indexOf(e) === this.selectedIndex,
|
|
);
|
|
lines.push(
|
|
theme.fg("dim", ` (${selectablePosition + 1}/${selectableTotal})`),
|
|
);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
const kb = getEditorKeybindings();
|
|
|
|
if (kb.matches(data, "selectUp")) {
|
|
this.selectedIndex = this.findNextItem(this.selectedIndex, -1);
|
|
return;
|
|
}
|
|
if (kb.matches(data, "selectDown")) {
|
|
this.selectedIndex = this.findNextItem(this.selectedIndex, 1);
|
|
return;
|
|
}
|
|
if (kb.matches(data, "selectPageUp")) {
|
|
// Jump up by maxVisible, then find nearest item
|
|
let target = Math.max(0, this.selectedIndex - this.maxVisible);
|
|
while (
|
|
target < this.filteredItems.length &&
|
|
this.filteredItems[target].type !== "item"
|
|
) {
|
|
target++;
|
|
}
|
|
if (target < this.filteredItems.length) {
|
|
this.selectedIndex = target;
|
|
}
|
|
return;
|
|
}
|
|
if (kb.matches(data, "selectPageDown")) {
|
|
// Jump down by maxVisible, then find nearest item
|
|
let target = Math.min(
|
|
this.filteredItems.length - 1,
|
|
this.selectedIndex + this.maxVisible,
|
|
);
|
|
while (target >= 0 && this.filteredItems[target].type !== "item") {
|
|
target--;
|
|
}
|
|
if (target >= 0) {
|
|
this.selectedIndex = target;
|
|
}
|
|
return;
|
|
}
|
|
if (kb.matches(data, "selectCancel")) {
|
|
this.onCancel?.();
|
|
return;
|
|
}
|
|
if (matchesKey(data, "ctrl+c")) {
|
|
this.onExit?.();
|
|
return;
|
|
}
|
|
if (data === " " || kb.matches(data, "selectConfirm")) {
|
|
const entry = this.filteredItems[this.selectedIndex];
|
|
if (entry?.type === "item") {
|
|
const newEnabled = !entry.item.enabled;
|
|
this.toggleResource(entry.item, newEnabled);
|
|
this.updateItem(entry.item, newEnabled);
|
|
this.onToggle?.(entry.item, newEnabled);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Pass to search input
|
|
this.searchInput.handleInput(data);
|
|
this.filterItems(this.searchInput.getValue());
|
|
}
|
|
|
|
private toggleResource(item: ResourceItem, enabled: boolean): void {
|
|
if (item.metadata.origin === "top-level") {
|
|
this.toggleTopLevelResource(item, enabled);
|
|
} else {
|
|
this.togglePackageResource(item, enabled);
|
|
}
|
|
}
|
|
|
|
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
|
|
const scope = item.metadata.scope as "user" | "project";
|
|
const settings =
|
|
scope === "project"
|
|
? this.settingsManager.getProjectSettings()
|
|
: this.settingsManager.getGlobalSettings();
|
|
|
|
const arrayKey = item.resourceType as
|
|
| "extensions"
|
|
| "skills"
|
|
| "prompts"
|
|
| "themes";
|
|
const current = (settings[arrayKey] ?? []) as string[];
|
|
|
|
// Generate pattern for this resource
|
|
const pattern = this.getResourcePattern(item);
|
|
const disablePattern = `-${pattern}`;
|
|
const enablePattern = `+${pattern}`;
|
|
|
|
// Filter out existing patterns for this resource
|
|
const updated = current.filter((p) => {
|
|
const stripped =
|
|
p.startsWith("!") || p.startsWith("+") || p.startsWith("-")
|
|
? p.slice(1)
|
|
: p;
|
|
return stripped !== pattern;
|
|
});
|
|
|
|
if (enabled) {
|
|
updated.push(enablePattern);
|
|
} else {
|
|
updated.push(disablePattern);
|
|
}
|
|
|
|
if (scope === "project") {
|
|
if (arrayKey === "extensions") {
|
|
this.settingsManager.setProjectExtensionPaths(updated);
|
|
} else if (arrayKey === "skills") {
|
|
this.settingsManager.setProjectSkillPaths(updated);
|
|
} else if (arrayKey === "prompts") {
|
|
this.settingsManager.setProjectPromptTemplatePaths(updated);
|
|
} else if (arrayKey === "themes") {
|
|
this.settingsManager.setProjectThemePaths(updated);
|
|
}
|
|
} else {
|
|
if (arrayKey === "extensions") {
|
|
this.settingsManager.setExtensionPaths(updated);
|
|
} else if (arrayKey === "skills") {
|
|
this.settingsManager.setSkillPaths(updated);
|
|
} else if (arrayKey === "prompts") {
|
|
this.settingsManager.setPromptTemplatePaths(updated);
|
|
} else if (arrayKey === "themes") {
|
|
this.settingsManager.setThemePaths(updated);
|
|
}
|
|
}
|
|
}
|
|
|
|
private togglePackageResource(item: ResourceItem, enabled: boolean): void {
|
|
const scope = item.metadata.scope as "user" | "project";
|
|
const settings =
|
|
scope === "project"
|
|
? this.settingsManager.getProjectSettings()
|
|
: this.settingsManager.getGlobalSettings();
|
|
|
|
const packages = [...(settings.packages ?? [])] as PackageSource[];
|
|
const pkgIndex = packages.findIndex((pkg) => {
|
|
const source = typeof pkg === "string" ? pkg : pkg.source;
|
|
return source === item.metadata.source;
|
|
});
|
|
|
|
if (pkgIndex === -1) return;
|
|
|
|
let pkg = packages[pkgIndex];
|
|
|
|
// Convert string to object form if needed
|
|
if (typeof pkg === "string") {
|
|
pkg = { source: pkg };
|
|
packages[pkgIndex] = pkg;
|
|
}
|
|
|
|
// Get the resource array for this type
|
|
const arrayKey = item.resourceType as
|
|
| "extensions"
|
|
| "skills"
|
|
| "prompts"
|
|
| "themes";
|
|
const current = (pkg[arrayKey] ?? []) as string[];
|
|
|
|
// Generate pattern relative to package root
|
|
const pattern = this.getPackageResourcePattern(item);
|
|
const disablePattern = `-${pattern}`;
|
|
const enablePattern = `+${pattern}`;
|
|
|
|
// Filter out existing patterns for this resource
|
|
const updated = current.filter((p) => {
|
|
const stripped =
|
|
p.startsWith("!") || p.startsWith("+") || p.startsWith("-")
|
|
? p.slice(1)
|
|
: p;
|
|
return stripped !== pattern;
|
|
});
|
|
|
|
if (enabled) {
|
|
updated.push(enablePattern);
|
|
} else {
|
|
updated.push(disablePattern);
|
|
}
|
|
|
|
(pkg as Record<string, unknown>)[arrayKey] =
|
|
updated.length > 0 ? updated : undefined;
|
|
|
|
// Clean up empty filter object
|
|
const hasFilters = ["extensions", "skills", "prompts", "themes"].some(
|
|
(k) => (pkg as Record<string, unknown>)[k] !== undefined,
|
|
);
|
|
if (!hasFilters) {
|
|
packages[pkgIndex] = (pkg as { source: string }).source;
|
|
}
|
|
|
|
if (scope === "project") {
|
|
this.settingsManager.setProjectPackages(packages);
|
|
} else {
|
|
this.settingsManager.setPackages(packages);
|
|
}
|
|
}
|
|
|
|
private getTopLevelBaseDir(scope: "user" | "project"): string {
|
|
return scope === "project"
|
|
? join(this.cwd, CONFIG_DIR_NAME)
|
|
: this.agentDir;
|
|
}
|
|
|
|
private getResourcePattern(item: ResourceItem): string {
|
|
const scope = item.metadata.scope as "user" | "project";
|
|
const baseDir = this.getTopLevelBaseDir(scope);
|
|
return relative(baseDir, item.path);
|
|
}
|
|
|
|
private getPackageResourcePattern(item: ResourceItem): string {
|
|
const baseDir = item.metadata.baseDir ?? dirname(item.path);
|
|
return relative(baseDir, item.path);
|
|
}
|
|
}
|
|
|
|
export class ConfigSelectorComponent extends Container implements Focusable {
|
|
private resourceList: ResourceList;
|
|
|
|
private _focused = false;
|
|
get focused(): boolean {
|
|
return this._focused;
|
|
}
|
|
set focused(value: boolean) {
|
|
this._focused = value;
|
|
this.resourceList.focused = value;
|
|
}
|
|
|
|
constructor(
|
|
resolvedPaths: ResolvedPaths,
|
|
settingsManager: SettingsManager,
|
|
cwd: string,
|
|
agentDir: string,
|
|
onClose: () => void,
|
|
onExit: () => void,
|
|
requestRender: () => void,
|
|
) {
|
|
super();
|
|
|
|
const groups = buildGroups(resolvedPaths);
|
|
|
|
// Add header
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new ConfigSelectorHeader());
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Resource list
|
|
this.resourceList = new ResourceList(
|
|
groups,
|
|
settingsManager,
|
|
cwd,
|
|
agentDir,
|
|
);
|
|
this.resourceList.onCancel = onClose;
|
|
this.resourceList.onExit = onExit;
|
|
this.resourceList.onToggle = () => requestRender();
|
|
this.addChild(this.resourceList);
|
|
|
|
// Bottom border
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
getResourceList(): ResourceList {
|
|
return this.resourceList;
|
|
}
|
|
}
|