feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,245 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ContentItem } from '@modrinth/ui'
import {
buildDependencyGraph,
dependencyGraphMetrics,
getConnectedComponents,
getDependencyTreeRows,
getRelatedNodeIds,
layoutDependencyGraph,
} from './dependency-graph.ts'
type Ref = { provider: 'modrinth'; projectId: string; releaseId: string }
type ItemOptions = {
title?: string
requires?: Ref[]
requiredBy?: Ref[]
autoDependency?: boolean
entryId?: string
filePath?: string
}
function item(projectId: string, versionId: string, options: ItemOptions = {}): ContentItem {
return {
id: projectId,
file_name: `${projectId}.jar`,
file_path: options.filePath ?? `mods/${projectId}.jar`,
size: 1,
enabled: true,
project_type: 'mod',
project: { id: projectId, slug: projectId, title: options.title ?? projectId, icon_url: null },
version: { id: versionId, version_number: versionId, file_name: `${projectId}.jar` },
update: null,
origin_provider: 'modrinth',
provider_refs: [{ provider: 'modrinth', project_id: projectId, version_id: versionId }],
instanceEntryId: options.entryId,
dependency: {
autoDependency: options.autoDependency ?? false,
requires: options.requires ?? [],
requiredBy: options.requiredBy ?? [],
orphaned: false,
},
} as ContentItem
}
const ref = (projectId: string, releaseId = '1') => ({
provider: 'modrinth' as const,
projectId,
releaseId,
})
function nodeId(projectId: string) {
return `item:modrinth:${projectId}:1`
}
test('builds dependency edges, roots, shared nodes, and relationship layout', () => {
const graph = buildDependencyGraph([
item('a', '1', { requires: [ref('b')] }),
item('c', '1', { requires: [ref('b')] }),
item('b', '1'),
])
assert.equal(graph.edges.length, 2)
assert.equal(graph.rootIds.length, 2)
assert.equal(graph.nodeById.get(nodeId('b'))?.shared, true)
assert.equal(layoutDependencyGraph(graph).edges.length, 2)
})
test('keeps unresolved dependency targets visible', () => {
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('missing', '9')] })])
assert.equal(graph.unresolvedIds.size, 1)
assert.equal(graph.edges[0]?.resolved, false)
assert.equal(graph.nodeById.get('missing:modrinth:missing:9')?.title, 'missing')
})
test('deduplicates edges and stops tree traversal at cycles and shared references', () => {
const graph = buildDependencyGraph([
item('a', '1', { requires: [ref('b'), ref('b')] }),
item('b', '1', { requires: [ref('a')] }),
])
assert.equal(graph.edges.length, 2)
assert.equal(graph.cycleIds.size, 2)
const rows = getDependencyTreeRows(graph, new Set([nodeId('a'), nodeId('b')]))
assert.ok(rows.some((row) => row.kind === 'cycle'))
assert.ok(rows.length < 6)
})
test('preserves isolated content as a root without dependency edges', () => {
const graph = buildDependencyGraph([item('standalone', '1')])
assert.deepEqual(graph.rootIds, [nodeId('standalone')])
assert.deepEqual(getDependencyTreeRows(graph, new Set()), [
{
id: `node:${nodeId('standalone')}:0`,
nodeId: nodeId('standalone'),
depth: 0,
kind: 'node',
hasChildren: false,
expanded: false,
},
])
assert.equal(layoutDependencyGraph(graph).nodes.length, 0)
})
test('keeps duplicate installed copies as distinct nodes and connects each matching copy', () => {
const graph = buildDependencyGraph([
item('parent', '1', { requires: [ref('library')] }),
item('library', '1', { entryId: 'library-a', filePath: 'mods/library-a.jar' }),
item('library', '1', { entryId: 'library-b', filePath: 'mods/library-b.jar' }),
])
assert.equal(graph.nodes.filter((node) => node.projectId === 'library').length, 2)
assert.equal(graph.edges.length, 2)
assert.ok(graph.nodeById.has('item:entry:library-a'))
assert.ok(graph.nodeById.has('item:entry:library-b'))
})
test('partitions unrelated relationships into compact graph components', () => {
const graph = buildDependencyGraph([
item('a', '1', { requires: [ref('b')] }),
item('b', '1'),
item('c', '1', { requires: [ref('d')] }),
item('d', '1'),
item('isolated', '1'),
])
const relationshipIds = new Set(graph.edges.flatMap((edge) => [edge.source, edge.target]))
const components = getConnectedComponents(graph, relationshipIds)
const layout = layoutDependencyGraph(graph)
assert.equal(components.length, 2)
assert.deepEqual(
components.map((component) => component.nodeIds.length),
[2, 2],
)
assert.equal(layout.components.length, 2)
assert.equal(layout.nodes.length, 4)
assert.equal(
layout.nodes.some((node) => node.id === nodeId('isolated')),
false,
)
})
test('returns the complete relationship context for a filtered node', () => {
const graph = buildDependencyGraph([
item('a', '1', { requires: [ref('b')] }),
item('b', '1', { requires: [ref('c')] }),
item('c', '1'),
item('isolated', '1'),
])
const related = getRelatedNodeIds(graph, new Set([nodeId('b')]))
assert.deepEqual(related, new Set([nodeId('a'), nodeId('b'), nodeId('c')]))
})
test('connects each edge from a source output port to a target input port with an HTML connector', () => {
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
const layout = layoutDependencyGraph(graph)
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
const edge = layout.edges[0]!
const expectedStartX =
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
const expectedStartY = source.y + dependencyGraphMetrics.nodeHeight / 2
const expectedEndX = target.x - dependencyGraphMetrics.edgeClearance
const expectedEndY = target.y + dependencyGraphMetrics.nodeHeight / 2
assert.equal(edge.connector.x, expectedStartX)
assert.equal(edge.connector.y, expectedStartY)
assert.equal(
edge.connector.length,
Math.hypot(expectedEndX - expectedStartX, expectedEndY - expectedStartY),
)
assert.equal(
edge.connector.rotation,
(Math.atan2(expectedEndY - expectedStartY, expectedEndX - expectedStartX) * 180) / Math.PI,
)
})
test('keeps output and input ports stable for reverse cycle connectors', () => {
const graph = buildDependencyGraph([
item('a', '1', { requires: [ref('b')] }),
item('b', '1', { requires: [ref('a')] }),
])
const layout = layoutDependencyGraph(graph)
const reverseEdge = layout.edges.find((edge) => edge.source === nodeId('b'))!
const source = layout.nodes.find((node) => node.id === reverseEdge.source)!
const target = layout.nodes.find((node) => node.id === reverseEdge.target)!
assert.equal(
reverseEdge.connector.x,
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
)
assert.equal(reverseEdge.connector.y, source.y + dependencyGraphMetrics.nodeHeight / 2)
assert.equal(
reverseEdge.connector.rotation,
(Math.atan2(
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
) *
180) /
Math.PI,
)
assert.ok(reverseEdge.connector.length > 0)
assert.equal(
reverseEdge.connector.length,
Math.hypot(
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
),
)
})
test('uses dragged final coordinates for connectors and canvas bounds', () => {
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
const offsets = new Map([[nodeId('b'), { x: 420, y: 180 }]])
const layout = layoutDependencyGraph(graph, undefined, offsets)
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
const edge = layout.edges[0]!
assert.ok(target.x >= dependencyGraphMetrics.canvasPadding + 420)
assert.ok(target.y >= dependencyGraphMetrics.canvasPadding + 180)
assert.equal(
edge.connector.length,
Math.hypot(
target.x - dependencyGraphMetrics.edgeClearance - edge.connector.x,
target.y + dependencyGraphMetrics.nodeHeight / 2 - edge.connector.y,
),
)
assert.equal(
edge.connector.x,
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
)
assert.ok(
layout.width >=
target.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
)
assert.ok(
layout.height >=
target.y + dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.canvasPadding,
)
})

View File

@ -0,0 +1,658 @@
import type { ContentItem } from '@modrinth/ui'
export type DependencyDirection = 'requires' | 'requiredBy'
export const dependencyGraphMetrics = {
canvasPadding: 56,
componentGap: 96,
edgeClearance: 2,
layerGap: 112,
minHeight: 360,
minWidth: 640,
nodeHeight: 76,
nodeWidth: 228,
rowGap: 30,
} as const
export type DependencyGraphNode = {
id: string
title: string
iconUrl?: string
projectId?: string
versionId?: string
versionNumber?: string
fileName?: string
projectType: string
provider: string
ownershipKind?: ContentItem['instanceOwnershipKind']
enabled?: boolean
materializationState?: ContentItem['instanceMaterializationState']
dependency: NonNullable<ContentItem['dependency']>
resolved: boolean
item?: ContentItem
cycle: boolean
shared: boolean
}
export type DependencyGraphEdge = {
id: string
source: string
target: string
resolved: boolean
}
export type DependencyGraph = {
nodes: DependencyGraphNode[]
edges: DependencyGraphEdge[]
nodeById: Map<string, DependencyGraphNode>
edgesBySource: Map<string, DependencyGraphEdge[]>
edgesByTarget: Map<string, DependencyGraphEdge[]>
rootIds: string[]
cycleIds: Set<string>
unresolvedIds: Set<string>
}
export type DependencyTreeRow = {
id: string
nodeId?: string
depth: number
kind: 'node' | 'reference' | 'cycle'
hasChildren: boolean
expanded: boolean
}
export type DependencyGraphConnector = {
length: number
rotation: number
x: number
y: number
}
export type DependencyGraphLayoutEdge = DependencyGraphEdge & {
connector: DependencyGraphConnector
}
export type DependencyGraphComponent = {
edgeCount: number
height: number
id: string
nodeIds: string[]
width: number
x: number
y: number
}
export type DependencyGraphLayout = {
components: DependencyGraphComponent[]
edges: DependencyGraphLayoutEdge[]
height: number
nodes: Array<DependencyGraphNode & { x: number; y: number }>
width: number
}
type DependencyReference = {
provider: string
projectId: string
releaseId: string
}
type NodePosition = {
x: number
y: number
}
type ComponentLayout = {
edges: DependencyGraphLayoutEdge[]
height: number
nodeIds: string[]
nodes: Array<DependencyGraphNode & NodePosition>
width: number
}
const emptyDependency = (): NonNullable<ContentItem['dependency']> => ({
autoDependency: false,
requiredBy: [],
requires: [],
orphaned: false,
})
function normalizeProjectId(provider: string, projectId: string): string {
return provider === 'curseforge' ? projectId.replace(/^curseforge:/, '') : projectId
}
function referenceKey(reference: DependencyReference): string {
return `${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:${reference.releaseId}`
}
function itemReferenceKeys(item: ContentItem): Set<string> {
const keys = new Set<string>()
const projectId = item.project?.id
const versionId = item.version?.id
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
if (projectId) keys.add(`project:${projectId}`)
if (provider && projectId) {
const normalizedProjectId = normalizeProjectId(provider, projectId)
keys.add(`${provider}:${normalizedProjectId}:`)
if (versionId) keys.add(`${provider}:${normalizedProjectId}:${versionId}`)
}
for (const providerRef of item.provider_refs) {
if (providerRef.provider === 'modrinth') {
keys.add(`modrinth:${providerRef.project_id}:${providerRef.version_id ?? ''}`)
keys.add(`modrinth:${providerRef.project_id}:`)
} else {
keys.add(`curseforge:${providerRef.project_id}:${providerRef.file_id ?? ''}`)
keys.add(`curseforge:${providerRef.project_id}:`)
}
}
return keys
}
function nodeIdForItem(item: ContentItem): string {
if (item.instanceEntryId) return `item:entry:${item.instanceEntryId}`
if (item.instanceMemberId) return `item:member:${item.instanceMemberId}`
if (item.instanceFileId) return `item:file:${item.instanceFileId}`
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
const projectId = item.project?.id
if (provider && projectId) {
return `item:${provider}:${normalizeProjectId(provider, projectId)}:${item.version?.id ?? ''}`
}
if (item.file_path) return `item:path:${item.file_path}`
if (item.file_name) return `item:name:${item.file_name}`
return `item:id:${item.id}`
}
function nodeFromItem(item: ContentItem, id: string): DependencyGraphNode {
const dependency = item.dependency ?? emptyDependency()
const provider = item.origin_provider ?? item.provider_refs[0]?.provider ?? 'local'
return {
id,
title: item.project?.title ?? item.file_name,
iconUrl: item.project?.icon_url,
projectId: item.project?.id,
versionId: item.version?.id,
versionNumber: item.version?.version_number,
fileName: item.file_name,
projectType: item.project_type,
provider,
ownershipKind: item.instanceOwnershipKind,
enabled: item.enabled,
materializationState: item.instanceMaterializationState,
dependency,
resolved: true,
item,
cycle: false,
shared: false,
}
}
function unresolvedNode(reference: DependencyReference): DependencyGraphNode {
return {
id: `missing:${referenceKey(reference)}`,
title: reference.projectId || reference.releaseId || 'Unresolved dependency',
projectId: reference.projectId,
versionId: reference.releaseId,
projectType: 'unknown',
provider: reference.provider,
dependency: emptyDependency(),
resolved: false,
cycle: false,
shared: false,
}
}
function addNodeForReference(
nodesByItemKey: Map<string, DependencyGraphNode[]>,
key: string,
node: DependencyGraphNode,
) {
const matches = nodesByItemKey.get(key) ?? []
if (!matches.some((candidate) => candidate.id === node.id)) matches.push(node)
nodesByItemKey.set(key, matches)
}
function findNodesForReference(
reference: DependencyReference,
nodesByItemKey: Map<string, DependencyGraphNode[]>,
): DependencyGraphNode[] {
const exact = nodesByItemKey.get(referenceKey(reference))
if (exact?.length) return exact
const byProviderProject = nodesByItemKey.get(
`${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:`,
)
if (byProviderProject?.length) return byProviderProject
return nodesByItemKey.get(`project:${reference.projectId}`) ?? []
}
function markCycles(
nodes: DependencyGraphNode[],
edgesBySource: Map<string, DependencyGraphEdge[]>,
): Set<string> {
const state = new Map<string, 0 | 1 | 2>()
const cycleIds = new Set<string>()
function visit(id: string, stack: string[]) {
const currentState = state.get(id) ?? 0
if (currentState === 2) return
if (currentState === 1) {
const cycleStart = stack.indexOf(id)
for (const cycleId of stack.slice(cycleStart)) cycleIds.add(cycleId)
return
}
state.set(id, 1)
for (const edge of edgesBySource.get(id) ?? []) visit(edge.target, [...stack, id])
state.set(id, 2)
}
for (const node of nodes) visit(node.id, [])
return cycleIds
}
export function buildDependencyGraph(items: ContentItem[]): DependencyGraph {
const nodesByItemKey = new Map<string, DependencyGraphNode[]>()
const nodes = new Map<string, DependencyGraphNode>()
for (const item of items) {
const id = nodeIdForItem(item)
const node = nodeFromItem(item, id)
nodes.set(id, node)
for (const key of itemReferenceKeys(item)) addNodeForReference(nodesByItemKey, key, node)
}
const edges = new Map<string, DependencyGraphEdge>()
const addEdge = (source: DependencyGraphNode, target: DependencyGraphNode) => {
if (!nodes.has(source.id)) nodes.set(source.id, source)
if (!nodes.has(target.id)) nodes.set(target.id, target)
const id = `${source.id}->${target.id}`
edges.set(id, { id, source: source.id, target: target.id, resolved: target.resolved })
}
for (const source of nodes.values()) {
for (const reference of source.dependency.requires as DependencyReference[]) {
const targets = findNodesForReference(reference, nodesByItemKey)
if (targets.length) {
for (const target of targets) addEdge(source, target)
} else {
addEdge(source, unresolvedNode(reference))
}
}
}
for (const target of nodes.values()) {
for (const reference of target.dependency.requiredBy as DependencyReference[]) {
const sources = findNodesForReference(reference, nodesByItemKey)
if (sources.length) {
for (const source of sources) addEdge(source, target)
} else {
addEdge(unresolvedNode(reference), target)
}
}
}
const allNodes = [...nodes.values()]
const allEdges = [...edges.values()]
const edgesBySource = new Map<string, DependencyGraphEdge[]>()
const edgesByTarget = new Map<string, DependencyGraphEdge[]>()
for (const edge of allEdges) {
const sourceEdges = edgesBySource.get(edge.source) ?? []
sourceEdges.push(edge)
edgesBySource.set(edge.source, sourceEdges)
const targetEdges = edgesByTarget.get(edge.target) ?? []
targetEdges.push(edge)
edgesByTarget.set(edge.target, targetEdges)
}
const cycleIds = markCycles(allNodes, edgesBySource)
const unresolvedIds = new Set(allNodes.filter((node) => !node.resolved).map((node) => node.id))
for (const node of allNodes) {
node.cycle = cycleIds.has(node.id)
node.shared = (edgesByTarget.get(node.id)?.length ?? 0) > 1
}
const rootIds = allNodes
.filter((node) => !(edgesByTarget.get(node.id)?.length ?? 0))
.map((node) => node.id)
.sort((a, b) => (nodes.get(a)!.title ?? '').localeCompare(nodes.get(b)!.title ?? ''))
const covered = new Set<string>()
const visitFromRoot = (id: string) => {
if (covered.has(id)) return
covered.add(id)
for (const edge of edgesBySource.get(id) ?? []) visitFromRoot(edge.target)
}
for (const rootId of rootIds) visitFromRoot(rootId)
for (const node of allNodes
.filter((candidate) => !covered.has(candidate.id))
.sort((a, b) => a.title.localeCompare(b.title))) {
rootIds.push(node.id)
}
return {
nodes: allNodes,
edges: allEdges,
nodeById: nodes,
edgesBySource,
edgesByTarget,
rootIds,
cycleIds,
unresolvedIds,
}
}
export function getDependencyTreeRows(
graph: DependencyGraph,
expandedIds: Set<string>,
direction: DependencyDirection = 'requires',
): DependencyTreeRow[] {
const rows: DependencyTreeRow[] = []
const seen = new Set<string>()
const roots =
direction === 'requires'
? graph.rootIds
: graph.nodes
.filter((node) => !(graph.edgesBySource.get(node.id)?.length ?? 0))
.map((node) => node.id)
.sort((a, b) => graph.nodeById.get(a)!.title.localeCompare(graph.nodeById.get(b)!.title))
function visit(nodeId: string, depth: number, stack: Set<string>) {
const node = graph.nodeById.get(nodeId)
if (!node) return
const edges =
direction === 'requires' ? graph.edgesBySource.get(nodeId) : graph.edgesByTarget.get(nodeId)
const children = (edges ?? []).map((edge) =>
direction === 'requires' ? edge.target : edge.source,
)
const expanded = expandedIds.has(nodeId)
const firstVisit = !seen.has(nodeId)
if (firstVisit) {
seen.add(nodeId)
rows.push({
id: `node:${nodeId}:${depth}`,
nodeId,
depth,
kind: 'node',
hasChildren: children.length > 0,
expanded,
})
} else {
rows.push({
id: `reference:${nodeId}:${depth}`,
nodeId,
depth,
kind: 'reference',
hasChildren: false,
expanded: false,
})
return
}
if (!expanded) return
for (const childId of children) {
if (stack.has(childId)) {
rows.push({
id: `cycle:${childId}:${depth + 1}`,
nodeId: childId,
depth: depth + 1,
kind: 'cycle',
hasChildren: false,
expanded: false,
})
continue
}
visit(childId, depth + 1, new Set([...stack, nodeId]))
}
}
for (const rootId of roots) visit(rootId, 0, new Set())
return rows
}
export function getRelatedNodeIds(
graph: DependencyGraph,
nodeIds: ReadonlySet<string>,
): Set<string> {
const related = new Set(nodeIds)
const queue = [...nodeIds]
while (queue.length) {
const id = queue.shift()!
for (const edge of [
...(graph.edgesBySource.get(id) ?? []),
...(graph.edgesByTarget.get(id) ?? []),
]) {
const next = edge.source === id ? edge.target : edge.source
if (!related.has(next)) {
related.add(next)
queue.push(next)
}
}
}
return related
}
export function getConnectedComponents(
graph: DependencyGraph,
visibleIds: ReadonlySet<string>,
): Array<{ edgeCount: number; nodeIds: string[] }> {
const components: Array<{ edgeCount: number; nodeIds: string[] }> = []
const remaining = new Set(visibleIds)
while (remaining.size) {
const start = remaining.values().next().value as string
const nodeIds = getRelatedNodeIds(graph, new Set([start]))
const componentIds = [...nodeIds].filter((id) => visibleIds.has(id)).sort()
for (const id of componentIds) remaining.delete(id)
const edgeCount = graph.edges.filter(
(edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target),
).length
components.push({ edgeCount, nodeIds: componentIds })
}
return components.sort(
(left, right) =>
right.edgeCount - left.edgeCount ||
right.nodeIds.length - left.nodeIds.length ||
left.nodeIds[0]!.localeCompare(right.nodeIds[0]!),
)
}
function nodeDepths(graph: DependencyGraph, visibleIds: ReadonlySet<string>): Map<string, number> {
const depths = new Map<string, number>()
const visiting = new Set<string>()
function depth(id: string): number {
const cached = depths.get(id)
if (cached !== undefined) return cached
if (visiting.has(id)) return 0
visiting.add(id)
const parents = (graph.edgesByTarget.get(id) ?? []).filter(
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
)
const value =
parents.length === 0 ? 0 : Math.max(...parents.map((edge) => depth(edge.source) + 1))
visiting.delete(id)
depths.set(id, value)
return value
}
for (const id of visibleIds) depth(id)
return depths
}
function edgeGeometry(
source: NodePosition,
target: NodePosition,
): Pick<DependencyGraphLayoutEdge, 'connector'> {
const x = source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
const y = source.y + dependencyGraphMetrics.nodeHeight / 2
const endX = target.x - dependencyGraphMetrics.edgeClearance
const endY = target.y + dependencyGraphMetrics.nodeHeight / 2
const dx = endX - x
const dy = endY - y
return {
connector: {
length: Math.hypot(dx, dy),
rotation: (Math.atan2(dy, dx) * 180) / Math.PI,
x,
y,
},
}
}
function layoutComponent(
graph: DependencyGraph,
nodeIds: string[],
nodeOffsets: ReadonlyMap<string, NodePosition>,
): ComponentLayout {
const visibleIds = new Set(nodeIds)
const nodesToLayout = graph.nodes.filter((node) => visibleIds.has(node.id))
const depths = nodeDepths(graph, visibleIds)
const groups = new Map<number, DependencyGraphNode[]>()
for (const node of nodesToLayout) {
const depth = depths.get(node.id) ?? 0
const group = groups.get(depth) ?? []
group.push(node)
groups.set(depth, group)
}
const ranks = new Map(
[...groups.keys()].sort((a, b) => a - b).map((depth, rank) => [depth, rank]),
)
const positions = new Map<string, NodePosition>()
for (const [depth, group] of groups) {
group.sort((left, right) => left.title.localeCompare(right.title))
const rank = ranks.get(depth) ?? 0
group.forEach((node, index) => {
const offset = nodeOffsets.get(node.id) ?? { x: 0, y: 0 }
positions.set(node.id, {
x: offset.x + rank * (dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.layerGap),
y: offset.y + index * (dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.rowGap),
})
})
}
const rawPositions = [...positions.values()]
const minX = Math.min(...rawPositions.map((position) => position.x))
const minY = Math.min(...rawPositions.map((position) => position.y))
const normalizedPositions = new Map<string, NodePosition>()
for (const [id, position] of positions) {
normalizedPositions.set(id, { x: position.x - minX, y: position.y - minY })
}
const nodes = nodesToLayout.map((node) => ({ ...node, ...normalizedPositions.get(node.id)! }))
const edges = graph.edges
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
.map((edge) => ({
...edge,
...edgeGeometry(normalizedPositions.get(edge.source)!, normalizedPositions.get(edge.target)!),
}))
return {
edges,
height: Math.max(
dependencyGraphMetrics.nodeHeight,
...nodes.map((node) => node.y + dependencyGraphMetrics.nodeHeight),
),
nodeIds,
nodes,
width: Math.max(
dependencyGraphMetrics.nodeWidth,
...nodes.map((node) => node.x + dependencyGraphMetrics.nodeWidth),
),
}
}
export function layoutDependencyGraph(
graph: DependencyGraph,
visibleIds: ReadonlySet<string> = new Set(graph.nodes.map((node) => node.id)),
nodeOffsets: ReadonlyMap<string, NodePosition> = new Map(),
): DependencyGraphLayout {
const relationshipIds = new Set(
graph.edges
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
.flatMap((edge) => [edge.source, edge.target]),
)
const components = getConnectedComponents(graph, relationshipIds)
if (!components.length) {
return {
components: [],
edges: [],
height: dependencyGraphMetrics.minHeight,
nodes: [],
width: dependencyGraphMetrics.minWidth,
}
}
const layouts = components.map((component) => ({
component,
layout: layoutComponent(graph, component.nodeIds, nodeOffsets),
}))
const rowWidth = Math.max(
dependencyGraphMetrics.minWidth - dependencyGraphMetrics.canvasPadding * 2,
Math.max(...layouts.map(({ layout }) => layout.width)),
)
let cursorX = dependencyGraphMetrics.canvasPadding
let cursorY = dependencyGraphMetrics.canvasPadding
let rowHeight = 0
const layoutComponents: DependencyGraphComponent[] = []
const nodes: DependencyGraphLayout['nodes'] = []
const edges: DependencyGraphLayout['edges'] = []
for (const { component, layout } of layouts) {
if (cursorX > dependencyGraphMetrics.canvasPadding && cursorX + layout.width > rowWidth) {
cursorX = dependencyGraphMetrics.canvasPadding
cursorY += rowHeight + dependencyGraphMetrics.componentGap
rowHeight = 0
}
const id = component.nodeIds.join('|')
layoutComponents.push({
edgeCount: component.edgeCount,
height: layout.height,
id,
nodeIds: component.nodeIds,
width: layout.width,
x: cursorX,
y: cursorY,
})
nodes.push(
...layout.nodes.map((node) => ({ ...node, x: node.x + cursorX, y: node.y + cursorY })),
)
edges.push(
...layout.edges.map((edge) => {
const source = layout.nodes.find((node) => node.id === edge.source)!
const target = layout.nodes.find((node) => node.id === edge.target)!
return {
...edge,
...edgeGeometry(
{ x: source.x + cursorX, y: source.y + cursorY },
{ x: target.x + cursorX, y: target.y + cursorY },
),
}
}),
)
cursorX += layout.width + dependencyGraphMetrics.componentGap
rowHeight = Math.max(rowHeight, layout.height)
}
return {
components: layoutComponents,
edges,
height: Math.max(
dependencyGraphMetrics.minHeight,
cursorY + rowHeight + dependencyGraphMetrics.canvasPadding,
),
nodes,
width: Math.max(
dependencyGraphMetrics.minWidth,
Math.max(
...nodes.map(
(node) =>
node.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
),
),
),
}
}

View File

@ -0,0 +1,275 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { NbtString, NbtTag } from 'deepslate/nbt'
import { computed, ref, watch } from 'vue'
import NbtTreeNode from './NbtTreeNode.vue'
import StudioEditor from './StudioEditor.vue'
const props = defineProps<{
content: string
filePath: string
readOnly?: boolean
}>()
const emit = defineEmits<{
'update:content': [content: string]
'update:mode': [mode: 'tree' | 'snbt']
format: []
save: []
}>()
const messages = defineMessages({
tree: { id: 'instance.files.studio.nbt.tree', defaultMessage: 'Tree' },
snbt: { id: 'instance.files.studio.nbt.snbt', defaultMessage: 'SNBT' },
})
const { formatMessage } = useVIntl()
const mode = ref<'tree' | 'snbt'>('tree')
const root = ref<NbtTag | null>(null)
const parseError = ref('')
const draft = ref(props.content)
const snbtEditor = ref<InstanceType<typeof StudioEditor> | null>(null)
const history = ref([props.content])
let historyIndex = 0
let lastEmittedContent = props.content
function parseContent(content: string) {
draft.value = content
try {
const parsed = NbtTag.fromString(content)
if (!parsed.isCompound()) throw new Error('NBT root must be a compound')
root.value = parsed
parseError.value = ''
} catch (error) {
parseError.value = error instanceof Error ? error.message : String(error)
}
}
function updateSnbt(content: string) {
draft.value = content
parseContent(content)
lastEmittedContent = content
emit('update:content', content)
}
watch(
() => props.content,
(content) => {
parseContent(content)
if (content !== lastEmittedContent) {
history.value = [content]
historyIndex = 0
lastEmittedContent = content
}
},
{ immediate: true },
)
const rootCompound = computed(() => (root.value?.isCompound() ? root.value : null))
function setMode(nextMode: 'tree' | 'snbt') {
if (nextMode === 'tree' && parseError.value) return
if (nextMode === 'tree') {
history.value = [draft.value]
historyIndex = 0
}
mode.value = nextMode
emit('update:mode', nextMode)
emit('save')
}
function handleFocusout(event: FocusEvent) {
const currentTarget = event.currentTarget
const nextTarget = event.relatedTarget
if (
currentTarget instanceof HTMLElement &&
nextTarget instanceof Node &&
currentTarget.contains(nextTarget)
)
return
emit('save')
}
function resolve(path: (string | number)[]) {
let current: NbtTag | undefined = root.value ?? undefined
for (const segment of path) {
if (!current) return undefined
if (typeof segment === 'string' && current.isCompound()) current = current.get(segment)
else if (typeof segment === 'number' && (current.isList() || current.isArray())) {
current = current.get(segment)
} else return undefined
}
return current
}
function parentOf(path: (string | number)[]) {
return resolve(path.slice(0, -1))
}
function updateContent() {
if (!root.value) return
const content = root.value.toPrettyString()
draft.value = content
if (history.value[historyIndex] !== content) {
history.value = history.value.slice(0, historyIndex + 1)
history.value.push(content)
historyIndex += 1
}
lastEmittedContent = content
emit('update:content', content)
}
function undo() {
if (mode.value !== 'tree' || historyIndex === 0) return
historyIndex -= 1
const content = history.value[historyIndex]
parseContent(content)
lastEmittedContent = content
emit('update:content', content)
}
function redo() {
if (mode.value !== 'tree' || historyIndex >= history.value.length - 1) return
historyIndex += 1
const content = history.value[historyIndex]
parseContent(content)
lastEmittedContent = content
emit('update:content', content)
}
function handleKeydown(event: KeyboardEvent) {
if (!(event.ctrlKey || event.metaKey)) return
if (event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
} else if (event.key.toLowerCase() === 'y') {
event.preventDefault()
redo()
}
}
function editValue(path: (string | number)[], value: string) {
const target = resolve(path)
if (!target) return
try {
const parsed = target.isString() ? new NbtString(value) : NbtTag.fromString(value)
if (parsed.getId() !== target.getId()) throw new Error('Value type cannot be changed')
const parent = parentOf(path)
const last = path.at(-1)
if (parent?.isCompound() && typeof last === 'string') parent.set(last, parsed)
else if (parent?.isList() && typeof last === 'number') parent.set(last, parsed)
else if (parent?.isByteArray() && typeof last === 'number' && parsed.isByte())
parent.set(last, parsed)
else if (parent?.isIntArray() && typeof last === 'number' && parsed.isInt())
parent.set(last, parsed)
else if (parent?.isLongArray() && typeof last === 'number' && parsed.isLong())
parent.set(last, parsed)
else throw new Error('Value cannot be changed')
parseError.value = ''
updateContent()
} catch (error) {
parseError.value = error instanceof Error ? error.message : String(error)
}
}
function removeValue(path: (string | number)[]) {
const parent = parentOf(path)
const last = path.at(-1)
if (parent?.isCompound() && typeof last === 'string') parent.delete(last)
else if (parent?.isListOrArray() && typeof last === 'number') parent.delete(last)
parseError.value = ''
updateContent()
}
function renameValue(path: (string | number)[], name: string) {
const parent = parentOf(path)
const oldName = path.at(-1)
if (!parent?.isCompound() || typeof oldName !== 'string' || parent.has(name)) return
const value = parent.get(oldName)
if (!value) return
parent.delete(oldName)
parent.set(name, value)
parseError.value = ''
updateContent()
}
function addValue(path: (string | number)[], input: string) {
const parent = resolve(path)
if (!parent) return
try {
const separator = parent.isCompound() ? input.indexOf(':') : -1
const name = separator === -1 ? undefined : input.slice(0, separator).trim()
const valueText = separator === -1 ? input.trim() : input.slice(separator + 1).trim()
const value = NbtTag.fromString(valueText)
if (parent.isCompound() && name && !parent.has(name)) parent.set(name, value)
else if (parent.isList() && value.getId() === parent.getType()) parent.add(value)
else if (parent.isByteArray() && value.isByte()) parent.add(value)
else if (parent.isIntArray() && value.isInt()) parent.add(value)
else if (parent.isLongArray() && value.isLong()) parent.add(value)
else throw new Error('Value type does not match the container')
parseError.value = ''
updateContent()
} catch (error) {
parseError.value = error instanceof Error ? error.message : String(error)
}
}
async function formatDocument() {
await snbtEditor.value?.formatDocument()
}
defineExpose({ formatDocument })
</script>
<template>
<div class="flex size-full min-h-0 min-w-0 flex-col bg-surface-2" @focusout="handleFocusout">
<div
class="flex h-10 shrink-0 items-center gap-1 border-0 border-b border-solid border-surface-4 px-3"
>
<button
v-for="candidate in ['tree', 'snbt'] as const"
:key="candidate"
type="button"
class="rounded border-0 px-3 py-1 text-xs font-semibold capitalize"
:class="
mode === candidate
? 'bg-brand text-contrast'
: 'bg-transparent text-secondary hover:bg-surface-3'
"
@click="setMode(candidate)"
>
{{ formatMessage(messages[candidate]) }}
</button>
<span v-if="parseError" class="ml-2 truncate text-xs text-red">{{ parseError }}</span>
</div>
<div
v-if="mode === 'tree'"
class="min-h-0 flex-1 overflow-auto p-2"
tabindex="0"
@keydown="handleKeydown"
>
<NbtTreeNode
v-if="rootCompound"
:tag="rootCompound"
:path="[]"
:depth="0"
:read-only="readOnly"
@edit="editValue"
@remove="removeValue"
@rename="renameValue"
@add="addValue"
/>
</div>
<StudioEditor
v-else
ref="snbtEditor"
:file-path="filePath"
:content="draft"
:read-only="readOnly"
:language="'snbt'"
@update:content="updateSnbt"
/>
</div>
</template>

View File

@ -0,0 +1,222 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { type NbtTag, NbtType } from 'deepslate/nbt'
import { computed, ref } from 'vue'
import NbtTypeIcon from './NbtTypeIcon.vue'
const props = defineProps<{
name?: string
tag: NbtTag
path: (string | number)[]
depth: number
readOnly?: boolean
}>()
const emit = defineEmits<{
edit: [path: (string | number)[], value: string]
remove: [path: (string | number)[]]
rename: [path: (string | number)[], value: string]
add: [path: (string | number)[], value: string]
}>()
const messages = defineMessages({
collapse: { id: 'instance.files.studio.nbt.collapse', defaultMessage: 'Collapse node' },
expand: { id: 'instance.files.studio.nbt.expand', defaultMessage: 'Expand node' },
add: { id: 'instance.files.studio.nbt.add', defaultMessage: 'Add child' },
remove: { id: 'instance.files.studio.nbt.remove', defaultMessage: 'Remove node' },
addPlaceholder: {
id: 'instance.files.studio.nbt.add-placeholder',
defaultMessage: 'name:value',
},
})
const { formatMessage } = useVIntl()
const expanded = ref(props.depth < 1)
const editing = ref(false)
const renaming = ref(false)
const draft = ref('')
const renameDraft = ref(props.name ?? '')
const adding = ref(false)
const addDraft = ref('')
const expandable = computed(
() =>
props.tag.isCompound() ||
props.tag.isList() ||
props.tag.isByteArray() ||
props.tag.isIntArray() ||
props.tag.isLongArray(),
)
const typeName = computed(() => NbtType[props.tag.getId()])
const displayValue = computed(() => {
if (props.tag.isCompound()) return `${props.tag.size} entries`
if (props.tag.isList()) return `${props.tag.length} ${NbtType[props.tag.getType()]} values`
if (props.tag.isArray()) return `${props.tag.length} values`
return props.tag.toString()
})
function children(): Array<{ name?: string; tag: NbtTag; path: (string | number)[] }> {
if (props.tag.isCompound()) {
return [...props.tag.keys()].map((name) => ({
name,
tag: props.tag.get(name)!,
path: [...props.path, name],
}))
}
if (props.tag.isList()) {
return Array.from({ length: props.tag.length }, (_, index) => ({
tag: props.tag.get(index),
path: [...props.path, index],
}))
}
if (props.tag.isArray()) {
return Array.from({ length: props.tag.length }, (_, index) => ({
tag: props.tag.get(index),
path: [...props.path, index],
}))
}
return []
}
function beginEdit() {
if (props.readOnly || expandable.value) return
draft.value = props.tag.isString() ? props.tag.getAsString() : props.tag.toString()
editing.value = true
}
function commitEdit() {
if (draft.value.trim()) emit('edit', props.path, draft.value)
editing.value = false
}
function commitRename() {
if (renameDraft.value.trim() && renameDraft.value !== props.name) {
emit('rename', props.path, renameDraft.value.trim())
}
renaming.value = false
}
function commitAdd() {
if (addDraft.value.trim()) emit('add', props.path, addDraft.value)
addDraft.value = ''
adding.value = false
}
function forwardEdit(path: (string | number)[], value: string) {
emit('edit', path, value)
}
function forwardRename(path: (string | number)[], value: string) {
emit('rename', path, value)
}
function forwardAdd(path: (string | number)[], value: string) {
emit('add', path, value)
}
</script>
<template>
<div>
<div
class="group flex min-h-8 items-center gap-2 rounded px-2 text-sm hover:bg-surface-3"
:style="{ paddingLeft: `${depth * 1.25 + 0.5}rem` }"
>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-secondary"
:class="expandable ? 'cursor-pointer' : 'cursor-default'"
:aria-label="formatMessage(expanded ? messages.collapse : messages.expand)"
@click="expandable && (expanded = !expanded)"
>
<span v-if="expandable">{{ expanded ? '' : '' }}</span>
</button>
<NbtTypeIcon :type="tag.getId()" />
<template v-if="name !== undefined">
<input
v-if="renaming"
v-model="renameDraft"
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-1 text-sm text-contrast"
@blur="commitRename"
@keydown.enter.prevent="commitRename"
/>
<button
v-else
type="button"
class="shrink-0 border-0 bg-transparent p-0 text-secondary"
:class="{ 'cursor-text': !readOnly }"
@dblclick="!readOnly && (renaming = true)"
>
{{ name }}:
</button>
</template>
<span class="text-xs text-secondary">{{ typeName }}</span>
<input
v-if="editing"
v-model="draft"
autofocus
class="min-w-0 flex-1 rounded border border-brand bg-surface-1 px-2 py-0.5 font-mono text-xs text-contrast"
@blur="commitEdit"
@keydown.enter.prevent="commitEdit"
@keydown.escape="editing = false"
/>
<button
v-else
type="button"
class="min-w-0 truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-primary"
:class="{ 'cursor-text': !expandable && !readOnly }"
@dblclick="beginEdit"
>
{{ displayValue }}
</button>
<button
v-if="!readOnly && expandable"
type="button"
class="ml-auto hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-contrast"
:aria-label="formatMessage(messages.add)"
@click="adding = !adding"
>
+
</button>
<button
v-if="!readOnly && path.length > 0"
type="button"
class="hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-red"
:aria-label="formatMessage(messages.remove)"
@click="emit('remove', path)"
>
×
</button>
</div>
<div
v-if="adding"
class="flex items-center gap-2 px-3 py-1"
:style="{ paddingLeft: `${(depth + 1) * 1.25 + 2.25}rem` }"
>
<input
v-model="addDraft"
autofocus
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-2 py-1 font-mono text-xs text-contrast"
:placeholder="formatMessage(messages.addPlaceholder)"
@keydown.enter="commitAdd"
@blur="commitAdd"
@keydown.escape="adding = false"
/>
</div>
<div v-if="expanded && expandable">
<NbtTreeNode
v-for="child in children()"
:key="child.path.join('.')"
:name="child.name"
:tag="child.tag"
:path="child.path"
:depth="depth + 1"
:read-only="readOnly"
@edit="forwardEdit"
@remove="emit('remove', $event)"
@rename="forwardRename"
@add="forwardAdd"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { NbtType } from 'deepslate/nbt'
defineProps<{
type: NbtType
}>()
const colors: Record<NbtType, string> = {
[NbtType.End]: 'text-secondary',
[NbtType.Byte]: 'text-orange',
[NbtType.Short]: 'text-yellow',
[NbtType.Int]: 'text-green',
[NbtType.Long]: 'text-blue',
[NbtType.Float]: 'text-purple',
[NbtType.Double]: 'text-pink',
[NbtType.ByteArray]: 'text-orange',
[NbtType.String]: 'text-brand',
[NbtType.List]: 'text-cyan',
[NbtType.Compound]: 'text-contrast',
[NbtType.IntArray]: 'text-green',
[NbtType.LongArray]: 'text-blue',
}
const labels: Partial<Record<NbtType, string>> = {
[NbtType.Byte]: 'B',
[NbtType.Short]: 'S',
[NbtType.Int]: 'I',
[NbtType.Long]: 'L',
[NbtType.Float]: 'F',
[NbtType.Double]: 'D',
}
</script>
<template>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="size-4 shrink-0"
:class="colors[type]"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
>
<path v-if="type === NbtType.Compound" d="M3 2.5h7l3 3v8H3zM10 2.5v3h3" />
<path v-else-if="type === NbtType.List" d="M3 3h10M3 8h10M3 13h10M5 3v10" />
<path
v-else-if="type === NbtType.String"
d="M4 3h8M4 13h8M5 3c-3 2-3 8 0 10M11 3c3 2 3 8 0 10"
/>
<path
v-else-if="
type === NbtType.ByteArray || type === NbtType.IntArray || type === NbtType.LongArray
"
d="M4 2.5h8v11H4zM6.5 5h3M6.5 8h3M6.5 11h3"
/>
<path v-else d="M3 3h10v10H3z" />
<text
v-if="labels[type]"
x="8"
y="11"
fill="currentColor"
stroke="none"
text-anchor="middle"
font-size="7"
font-weight="700"
>
{{ labels[type] }}
</text>
</svg>
</template>

View File

@ -0,0 +1,279 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import type * as Monaco from 'monaco-editor'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
declare global {
interface Window {
__axolotlMonacoRuntime?: Promise<typeof Monaco>
require?: {
config(config: Record<string, unknown>): void
(dependencies: string[], callback: (monaco: typeof Monaco) => void): void
}
}
}
const props = defineProps<{
content: string
filePath: string
language: string
readOnly?: boolean
}>()
const emit = defineEmits<{
'update:content': [content: string]
save: []
blur: []
}>()
const messages = defineMessages({
loading: {
id: 'instance.files.studio.editor-loading',
defaultMessage: 'Loading editor...',
},
})
const { formatMessage } = useVIntl()
const editorElement = ref<HTMLElement | null>(null)
const loading = ref(true)
let monaco: typeof Monaco | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let model: Monaco.editor.ITextModel | null = null
let contentSubscription: Monaco.IDisposable | null = null
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
let applyingExternalContent = false
let disposed = false
function loadCodiconStyles() {
if (document.querySelector('link[data-monaco-codicons]')) return
const stylesheet = document.createElement('link')
stylesheet.rel = 'stylesheet'
stylesheet.href = '/monaco/codicon/codicon.css'
stylesheet.dataset.monacoCodicons = 'true'
document.head.append(stylesheet)
}
function loadMonaco(): Promise<typeof Monaco> {
if (window.__axolotlMonacoRuntime) return window.__axolotlMonacoRuntime
window.__axolotlMonacoRuntime = new Promise((resolve, reject) => {
loadCodiconStyles()
const initialize = () => {
const require = window.require
if (!require) {
reject(new Error('Monaco loader did not initialize'))
return
}
require.config({ paths: { vs: '/monaco/vs' } })
require(['vs/editor/editor.main'], (loadedMonaco: typeof Monaco) => resolve(loadedMonaco))
}
if (window.require) {
initialize()
return
}
const existingLoader = document.querySelector<HTMLScriptElement>('script[data-monaco-loader]')
if (existingLoader) {
existingLoader.addEventListener('load', initialize, { once: true })
existingLoader.addEventListener(
'error',
() => reject(new Error('Failed to load Monaco editor')),
{ once: true },
)
return
}
const loader = document.createElement('script')
loader.src = '/monaco/vs/loader.js'
loader.dataset.monacoLoader = 'true'
loader.onload = initialize
loader.onerror = () => reject(new Error('Failed to load Monaco editor'))
document.head.append(loader)
})
return window.__axolotlMonacoRuntime
}
function cssVariable(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
function applyTheme() {
if (!monaco) return
const isLight = document.documentElement.classList.contains('light-mode')
monaco.editor.defineTheme('axolotl-studio', {
base: isLight ? 'vs' : 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': cssVariable('--surface-2'),
'editor.foreground': cssVariable('--color-base'),
'editorGutter.background': cssVariable('--surface-2'),
'editorLineNumber.foreground': cssVariable('--color-secondary'),
'editor.lineHighlightBackground': cssVariable('--surface-3'),
'editorCursor.foreground': cssVariable('--color-brand'),
},
})
monaco.editor.setTheme('axolotl-studio')
}
function registerStudioLanguages() {
if (!monaco) return
if (!monaco.languages.getLanguages().some(({ id }) => id === 'toml')) {
monaco.languages.register({ id: 'toml', extensions: ['.toml'] })
monaco.languages.setMonarchTokensProvider('toml', {
tokenizer: {
root: [
[/#.*/, 'comment'],
[/\[\[?.*?\]\]?/, 'type.identifier'],
[/^[\w.-]+(?=\s*=)/, 'key'],
[/"([^"\\]|\\.)*"/, 'string'],
[/'[^']*'/, 'string'],
[/\b(true|false)\b/, 'keyword'],
[/[-+]?\b\d+(\.\d+)?\b/, 'number'],
],
},
})
}
if (!monaco.languages.getLanguages().some(({ id }) => id === 'properties')) {
monaco.languages.register({ id: 'properties', extensions: ['.properties'] })
monaco.languages.setMonarchTokensProvider('properties', {
tokenizer: {
root: [
[/^[#!].*$/, 'comment'],
[/^[^\s:=]+(?=\s*[:=])/, 'key'],
[/[:=]/, 'delimiter'],
[/\\./, 'string.escape'],
],
},
})
}
if (!monaco.languages.getLanguages().some(({ id }) => id === 'snbt')) {
monaco.languages.register({ id: 'snbt' })
monaco.languages.setMonarchTokensProvider('snbt', {
tokenizer: {
root: [
[/\/\/.*$/, 'comment'],
[/[{}[\],:]/, 'delimiter'],
[/(?:true|false)\b/, 'keyword'],
[/-?(?:\d+\.?\d*|\.\d+)(?:[bBsSlLfFdD])?\b/, 'number'],
[/'(?:[^'\\]|\\.)*'/, 'string'],
[/"(?:[^"\\]|\\.)*"/, 'string'],
[/[A-Za-z0-9_.+-]+(?=\s*:)/, 'key'],
],
},
})
}
}
function createModel() {
if (!monaco || !editor) return
contentSubscription?.dispose()
model?.dispose()
model = monaco.editor.createModel(
props.content,
props.language,
monaco.Uri.parse(
`axolotl-instance://studio/${props.filePath.split('/').map(encodeURIComponent).join('/')}`,
),
)
editor.setModel(model)
contentSubscription = model.onDidChangeContent(() => {
if (!applyingExternalContent) emit('update:content', model?.getValue() ?? '')
})
}
onMounted(async () => {
try {
monaco = await loadMonaco()
} catch (error) {
loading.value = false
console.error('Failed to load Monaco editor', error)
return
}
if (disposed) return
registerStudioLanguages()
applyTheme()
if (!editorElement.value) return
editor = monaco.editor.create(editorElement.value, {
automaticLayout: false,
fontSize: 14,
fontLigatures: false,
minimap: { enabled: true },
padding: { top: 12 },
readOnly: props.readOnly,
renderWhitespace: 'selection',
scrollBeyondLastLine: false,
theme: 'axolotl-studio',
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => emit('save'))
editor.onDidBlurEditorWidget(() => emit('blur'))
createModel()
resizeObserver = new ResizeObserver(() => editor?.layout())
resizeObserver.observe(editorElement.value)
themeObserver = new MutationObserver(applyTheme)
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
loading.value = false
})
watch(
() => props.filePath,
() => createModel(),
)
watch(
() => props.content,
(content) => {
if (!model || model.getValue() === content) return
applyingExternalContent = true
model.setValue(content)
applyingExternalContent = false
},
)
watch(
() => props.language,
(language) => {
if (monaco && model) monaco.editor.setModelLanguage(model, language)
},
)
watch(
() => props.readOnly,
(readOnly) => editor?.updateOptions({ readOnly }),
)
onBeforeUnmount(() => {
disposed = true
contentSubscription?.dispose()
resizeObserver?.disconnect()
themeObserver?.disconnect()
editor?.dispose()
model?.dispose()
})
async function formatDocument() {
await editor?.getAction('editor.action.formatDocument')?.run()
}
defineExpose({ formatDocument })
</script>
<template>
<div class="relative size-full min-h-0 min-w-0 bg-surface-2">
<div
v-if="loading"
class="absolute inset-0 z-[1] flex items-center justify-center text-sm text-secondary"
>
{{ formatMessage(messages.loading) }}
</div>
<div ref="editorElement" class="size-full min-h-0 min-w-0" />
</div>
</template>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import { FileCodeIcon, XIcon } from '@modrinth/assets'
import { commonMessages, useVIntl } from '@modrinth/ui'
import type { StudioDocument } from './useStudioDocuments'
defineProps<{
documents: StudioDocument[]
activePath: string
}>()
const emit = defineEmits<{
activate: [path: string]
close: [path: string]
}>()
const { formatMessage } = useVIntl()
let middleClickPath: string | null = null
function handleWheel(event: WheelEvent) {
const container = event.currentTarget as HTMLElement
if (container.scrollWidth <= container.clientWidth) return
event.preventDefault()
container.scrollLeft += event.deltaY || event.deltaX
}
function handleAuxClick(event: MouseEvent, path: string) {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
if (middleClickPath === path) {
middleClickPath = null
return
}
emit('close', path)
}
function handleMouseDown(event: MouseEvent, path: string) {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
middleClickPath = path
emit('close', path)
}
</script>
<template>
<div class="flex h-full min-w-0 flex-1 overflow-x-auto" @wheel="handleWheel">
<div
v-for="document in documents"
:key="document.path"
role="tab"
tabindex="0"
:aria-selected="document.path === activePath"
class="flex h-full max-w-[14rem] min-w-[8rem] shrink-0 select-none items-center gap-2 border-0 border-r border-solid border-surface-4 px-3 text-left text-sm text-secondary hover:bg-surface-2"
:class="{ 'bg-surface-2 !text-contrast': document.path === activePath }"
@click="emit('activate', document.path)"
@mousedown="handleMouseDown($event, document.path)"
@auxclick="handleAuxClick($event, document.path)"
@keydown.enter="emit('activate', document.path)"
@keydown.space.prevent="emit('activate', document.path)"
>
<XIcon v-if="document.kind === 'unsupported'" class="size-4 shrink-0 text-red" />
<FileCodeIcon v-else class="size-4 shrink-0 text-secondary" />
<span class="min-w-0 flex-1 truncate">{{ document.name }}</span>
<span
v-if="document.content !== document.savedContent"
class="size-2 shrink-0 rounded-full bg-brand"
/>
<button
type="button"
:aria-label="formatMessage(commonMessages.closeButton)"
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-secondary hover:bg-surface-4 hover:text-contrast"
@pointerdown.stop
@click.stop.prevent="emit('close', document.path)"
>
<XIcon class="size-3.5" />
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,137 @@
import { computed, ref } from 'vue'
export interface StudioDocument {
kind: 'text' | 'nbt' | 'image' | 'video' | 'unsupported'
path: string
name: string
content: string
savedContent: string
saving: boolean
}
export function useStudioDocuments(
writeDocument: (document: StudioDocument, content: string) => Promise<void>,
onSaveError: (error: unknown) => void,
) {
const documents = ref<StudioDocument[]>([])
const activeIndex = ref(-1)
const savePromises = new Map<string, Promise<boolean>>()
const activeDocument = computed(() => documents.value[activeIndex.value] ?? null)
const activePath = computed(() => activeDocument.value?.path ?? '')
const hasUnsavedChanges = computed(
() =>
activeDocument.value !== null &&
activeDocument.value.content !== activeDocument.value.savedContent,
)
const hasAnyUnsavedChanges = computed(() =>
documents.value.some((document) => document.content !== document.savedContent),
)
function saveDocument(document: StudioDocument | null): Promise<boolean> {
if (
!document ||
(document.kind !== 'text' && document.kind !== 'nbt') ||
document.content === document.savedContent
) {
return Promise.resolve(true)
}
const existingPromise = savePromises.get(document.path)
if (existingPromise) return existingPromise
document.saving = true
const contentToSave = document.content
const savePromise = writeDocument(document, contentToSave)
.then(() => {
document.savedContent = contentToSave
return true
})
.catch((error) => {
onSaveError(error)
return false
})
.finally(() => {
document.saving = false
savePromises.delete(document.path)
})
savePromises.set(document.path, savePromise)
return savePromise
}
async function activate(path: string) {
if (path === activePath.value) return true
if (!(await saveDocument(activeDocument.value))) return false
const nextIndex = documents.value.findIndex((document) => document.path === path)
if (nextIndex === -1) return false
activeIndex.value = nextIndex
return true
}
async function open(document: StudioDocument) {
const existing = documents.value.find((candidate) => candidate.path === document.path)
if (existing) return activate(existing.path)
if (!(await saveDocument(activeDocument.value))) return false
documents.value.push(document)
activeIndex.value = documents.value.length - 1
return true
}
async function close(path: string) {
const index = documents.value.findIndex((document) => document.path === path)
if (index === -1) return false
if (!(await saveDocument(documents.value[index]))) return false
const wasActive = activeIndex.value === index
documents.value.splice(index, 1)
if (documents.value.length === 0) {
activeIndex.value = -1
} else if (wasActive) {
activeIndex.value = Math.min(index, documents.value.length - 1)
} else if (index < activeIndex.value) {
activeIndex.value -= 1
}
return true
}
function updateActiveContent(content: string) {
if (activeDocument.value) activeDocument.value.content = content
}
function discardActiveChanges() {
if (activeDocument.value) activeDocument.value.content = activeDocument.value.savedContent
}
async function saveActive() {
return saveDocument(activeDocument.value)
}
async function saveAll() {
const results = await Promise.all(documents.value.map((document) => saveDocument(document)))
return results.every(Boolean)
}
function reset() {
documents.value = []
activeIndex.value = -1
savePromises.clear()
}
return {
documents,
activeDocument,
activePath,
hasUnsavedChanges,
hasAnyUnsavedChanges,
activate,
open,
close,
saveDocument,
saveActive,
saveAll,
updateActiveContent,
discardActiveChanges,
reset,
}
}