Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 289 additions & 81 deletions packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import {
applyRightPanelItemCustomization,
collectRightPanelItems,
moveRightPanelItem,
parseRightPanelCustomization,
setRightPanelItemHidden,
type RightPanelItem,
type RightPanelTabModule,
} from "./registry"

const item = (id: string, order: number, alwaysVisible = false): RightPanelItem => ({ id, labelKey: id, order, alwaysVisible })
const tab = (id: string, order: number): RightPanelTabModule => ({ ...item(id, order), render: () => undefined as any })

describe("right panel registry", () => {
it("collects and orders module items", () => {
const items = collectRightPanelItems(
[
{ id: "core", tabs: [tab("status", 40), tab("changes", 10)] },
{ id: "plugin", tabs: [tab("custom", 30)] },
],
"tabs",
)

assert.deepEqual(items.map((entry) => entry.id), ["changes", "custom", "status"])
})

it("rejects duplicate item ids", () => {
assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs"))
})

it("applies visibility and user order", () => {
const visible = applyRightPanelItemCustomization(
[item("changes", 10), item("files", 20), item("status", 30)],
["status", "changes"],
["files"],
)

assert.deepEqual(visible.map((entry) => entry.id), ["status", "changes"])
})

it("keeps always-visible items visible", () => {
const visible = applyRightPanelItemCustomization([item("configure", 100, true)], [], ["configure"])

assert.deepEqual(visible.map((entry) => entry.id), ["configure"])
})

it("moves items in normalized order", () => {
assert.deepEqual(moveRightPanelItem(["changes", "status", "files"], ["changes", "files", "status"], "status", -1), [
"status",
"changes",
"files",
])
})

it("parses invalid customization safely", () => {
assert.deepEqual(parseRightPanelCustomization("{"), {
tabOrder: [],
hiddenTabIds: [],
statusSectionOrder: [],
hiddenStatusSectionIds: [],
})
})

it("toggles hidden ids", () => {
assert.deepEqual(setRightPanelItemHidden(["files"], "status", true), ["files", "status"])
assert.deepEqual(setRightPanelItemHidden(["files"], "files", false), [])
})
})
113 changes: 113 additions & 0 deletions packages/ui/src/components/instance/shell/right-panel/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { JSX } from "solid-js"

export interface RightPanelItem {
id: string
labelKey: string
order: number
alwaysVisible?: boolean
}

export interface RightPanelTabModule extends RightPanelItem {
render: () => JSX.Element
}

export interface RightPanelSectionModule extends RightPanelItem {
tooltipKey: string
defaultExpanded?: boolean
render: () => JSX.Element
}

export interface RightPanelModule {
id: string
tabs?: readonly RightPanelTabModule[]
statusSections?: readonly RightPanelSectionModule[]
}

export interface RightPanelCustomization {
tabOrder: string[]
hiddenTabIds: string[]
statusSectionOrder: string[]
hiddenStatusSectionIds: string[]
}

export const DEFAULT_RIGHT_PANEL_CUSTOMIZATION: RightPanelCustomization = {
tabOrder: [],
hiddenTabIds: [],
statusSectionOrder: [],
hiddenStatusSectionIds: [],
}

export function parseRightPanelCustomization(value: string | null): RightPanelCustomization {
if (!value) return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION }
try {
const parsed = JSON.parse(value) as Partial<RightPanelCustomization>
return {
tabOrder: readStringArray(parsed.tabOrder),
hiddenTabIds: readStringArray(parsed.hiddenTabIds),
statusSectionOrder: readStringArray(parsed.statusSectionOrder),
hiddenStatusSectionIds: readStringArray(parsed.hiddenStatusSectionIds),
}
} catch {
return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION }
}
}

export function collectRightPanelItems<T extends RightPanelItem>(modules: readonly RightPanelModule[], key: "tabs" | "statusSections"): T[] {
const items = modules.flatMap((module) => [...((module[key] ?? []) as unknown as readonly T[])])
const seen = new Set<string>()
for (const item of items) {
if (!item.id) throw new Error(`Right panel ${key} must define an id`)
if (seen.has(item.id)) throw new Error(`Duplicate right panel ${key} id: ${item.id}`)
seen.add(item.id)
}
return sortRightPanelItems(items, [])
}

export function applyRightPanelItemCustomization<T extends RightPanelItem>(
items: readonly T[],
orderedIds: readonly string[],
hiddenIds: readonly string[],
): T[] {
const hidden = new Set(hiddenIds)
const visible = items.filter((item) => item.alwaysVisible || !hidden.has(item.id))
return sortRightPanelItems(visible.length > 0 ? visible : items.slice(0, 1), orderedIds)
}

export function moveRightPanelItem(orderedIds: readonly string[], allIds: readonly string[], id: string, direction: -1 | 1): string[] {
const order = normalizeOrder(orderedIds, allIds)
const index = order.indexOf(id)
const nextIndex = index + direction
if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return order
const next = [...order]
;[next[index], next[nextIndex]] = [next[nextIndex], next[index]]
return next
}

export function setRightPanelItemHidden(hiddenIds: readonly string[], id: string, hidden: boolean): string[] {
const next = new Set(hiddenIds)
if (hidden) next.add(id)
else next.delete(id)
return [...next]
}

function sortRightPanelItems<T extends RightPanelItem>(items: readonly T[], orderedIds: readonly string[]): T[] {
const rank = new Map(orderedIds.map((id, index) => [id, index]))
return [...items].sort((left, right) => {
const leftRank = rank.get(left.id)
const rightRank = rank.get(right.id)
if (leftRank !== undefined || rightRank !== undefined) {
return (leftRank ?? Number.MAX_SAFE_INTEGER) - (rightRank ?? Number.MAX_SAFE_INTEGER)
}
return left.order - right.order || left.id.localeCompare(right.id)
})
}

function normalizeOrder(orderedIds: readonly string[], allIds: readonly string[]): string[] {
const known = new Set(allIds)
const ordered = orderedIds.filter((id, index) => known.has(id) && orderedIds.indexOf(id) === index)
return [...ordered, ...allIds.filter((id) => !ordered.includes(id))]
}

function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []
}
Loading
Loading