import { coerce, isGreater, isGreaterOrEqual, isLess, isValid, sortReversed, } from "verkit"; import type { Project } from "../../projects"; import type { IconDescriptor } from "../applications"; import { BrettApplication } from "../brett-applications"; import type { DockGroup } from "../interfaces"; import type { Studio, Workspace } from "./schemas"; import { StudioWorkspace } from "./workspaces"; const DEFAULT_WORKSPACE_DATA = { name: "default", title: "Default", basePath: "/", } as const satisfies Omit; /** * @public */ export class StudioApplication extends BrettApplication { /** * Returns a list of studio workspaces based on the application manifest. * If there is no manifest, or alternatively it is not valid, then we create a default workspace. */ readonly workspaces: readonly StudioWorkspace[] = []; readonly project: Project; /** * The projects actually referenced by this studio — the application's own * project plus any project used by a workspace. Preserved so the instance * can be re-constructed (e.g. by dock wrappers) without having to thread * the full organization project list through again. */ readonly projects: Project[]; /** * @param application - The studio application to create a list of workspaces for * @param projects - The projects available in the organization. It's not enough to just pass * the project that associates with the application because that is the project the app is deployed in relation to. * The workspaces may have different projects completely. */ constructor( application: Studio, projects: Project[], options: { isLocal?: boolean; remoteApplication?: StudioApplication | null; } = {}, ) { super(application, "studio", options); /** * Workspaces ride the active deployment — a deployed studio's own, or the * ones a local dev server's manifest was adapted into upstream. Either way * the studio reads one shape and never branches on local vs deployed. */ let workspaces: Workspace[] = []; const deployedWorkspaces = application.activeDeployment?.workspaces; if (deployedWorkspaces?.length) { workspaces = deployedWorkspaces.map((workspace) => ({ name: workspace.name, title: workspace.title ?? workspace.name, subtitle: workspace.subtitle, basePath: workspace.basePath ?? "/", projectId: workspace.projectId, dataset: workspace.dataset, icon: workspace.icon, })); } /** * Filter all the workspaces that have a project the user does not have access to. */ const workspacesWithProjectsMap = workspaces.reduce((acc, workspace) => { const project = projects.find((p) => p.id === workspace.projectId); if (project) { acc.set(workspace, project); } else { console.warn( `Project not found for application ${application.id} and workspace ${workspace.name}. This workspace has been omitted.`, ); } return acc; }, new Map>()); const projectId = application.config.studio.projectId; const project = projects.find((p) => p.id === projectId); if (!project) { throw new Error(`Project not found for application ${application.id}`); } if (workspacesWithProjectsMap.size === 0) { /** * If there are still no workspaces, we create a default workspace. */ workspacesWithProjectsMap.set( { ...DEFAULT_WORKSPACE_DATA, projectId, } satisfies Workspace, project, ); } this.workspaces = Object.freeze( Array.from(workspacesWithProjectsMap.entries()).map(([workspace, p]) => { /** * A workspace is considered the default if the dashboard generated it OR because * the properties match that of the default workspace generated by the studio. * Which is why these values will match because dashboard generates an identical * workspace to the default studio one. */ const isDefaultWorkspace = workspace.name === DEFAULT_WORKSPACE_DATA.name && workspace.basePath === DEFAULT_WORKSPACE_DATA.basePath && workspace.title === DEFAULT_WORKSPACE_DATA.title; return this.createWorkspace(workspace, p, isDefaultWorkspace); }), ); this.project = project; const usedProjects = new Set>([project]); for (const workspace of this.workspaces) { usedProjects.add(workspace.project); } this.projects = Array.from(usedProjects); } /** * Factory hook called for each workspace during construction. Subclasses * override this to substitute a `StudioWorkspace` subclass (e.g. a UI-aware * variant in a downstream package) without re-implementing the constructor's * workspace-derivation logic. */ protected createWorkspace( workspace: Workspace, project: Project, isDefaultWorkspace: boolean, ): StudioWorkspace { return new StudioWorkspace(this, workspace, project, isDefaultWorkspace); } get href() { return this.isLocal ? `/local/${this.id}` : `/studio/${this.application.id}`; } get title() { // The application's own title has the highest precedence. const title = this.get("title"); if (title) { return title; } // A multi-workspace internal studio has no single meaningful title, so // fall back to the project's display name. if (this.workspaces.length > 1 && this.get("externalUrl") === null) { return this.project.displayName; } // Otherwise use the first workspace's title. return this.workspaces[0].title; } get subtitle() { return new URL(this.url).hostname; } /** * A single-workspace studio stands for that workspace, so it borrows the * workspace's icon and initials. */ get icon(): IconDescriptor { if (this.application.icon) { return { variant: "image", svg: this.application.icon }; } const [workspace] = this.workspaces; if (!workspace || this.workspaces.length > 1) { return { variant: "avatar", initials: this.initials, color: this.avatarColor, }; } const svg = workspace.workspace.icon; if (svg) { return { variant: "image", svg }; } return { variant: "avatar", initials: workspace.initials, color: this.avatarColor, }; } /** * Read off the `app` interface for local and deployed studios alike, since a * local studio's manifest value is adapted onto that interface upstream. */ get group(): DockGroup | undefined { return this.interfaces("app")[0]?.metadata?.group as DockGroup | undefined; } get priority(): number | undefined { return this.interfaces("app")[0]?.metadata?.priority; } get(attr: TKey): Studio[TKey] { if (!(attr in this.application)) { throw new Error( `Attribute ${attr.toString()} does not exist on studio ${this.application.id}`, ); } return this.application[attr]; } get hasManifest(): boolean { return Boolean(this.application.activeDeployment?.workspaces?.length); } get hasSchema(): boolean { // Read from the deployment, not `this.workspaces` — the latter omits // workspaces the user can't access, but the studio still has their schema. const deployedWorkspaces = this.application.activeDeployment?.workspaces; if (!deployedWorkspaces?.length) return false; return deployedWorkspaces.every((w) => w.schemaDescriptorId !== null); } private resolveVersion(): string | null { const version = this.get("activeDeployment")?.version; const coerced = version ? coerce(version) : null; if (!coerced || !isValid(coerced)) { return null; } return coerced; } get version() { return this.resolveVersion(); } get compatibilityStatus(): CompatibilityStatus { return StudioApplication.resolveCompatibilityStatus(this); } /** * Used to calculate the compatibility status of a given studio application. * Optionally if you've resolved the version elsewhere provide that value to * get the new compatibility status without mutating the application. */ static resolveCompatibilityStatus( application: StudioApplication, version: string | null = application.version, ): CompatibilityStatus { if ( version === null || isLess(version, StudioApplication.MinimumStudioVersion) ) { return StudioApplication.CompatibilityStatuses.UNKNOWN; } if ( !application.hasSchema || !application.hasManifest || StudioApplication.resolveIssues(application, version).length > 0 ) { return StudioApplication.CompatibilityStatuses.PARTIALLY_COMPATIBLE; } return StudioApplication.CompatibilityStatuses.FULLY_COMPATIBLE; } get isAutoRedirecting(): boolean { return StudioApplication.resolveIsAutoRedirecting(this); } /** * Mirrors the `isRedirectable` function from Saison defined in * https://github.com/sanity-io/saison/blob/83556405d23e07f6d3a71c76249c67e33fe1101f/src/utils/applications.ts * * Returns whether a studio application is auto-redirecting, meaning it can only be accessed in the context of * Dashboard. */ static resolveIsAutoRedirecting( application: StudioApplication, versionArg = application.version, ) { let version: string | null = versionArg; if (application.get("externalUrl") !== null || !version) { return false; } if (!application.get("activeDeployment")?.isAutoUpdating) { // If the studio is not auto-updating, we need to check if the version supports // workspace switcher (3.92.0+) otherwise it would not be redirected. return isGreater(version, "3.92.0"); } const autoUpdatingVersion = application.get("config").studio.autoUpdatingVersion; if (autoUpdatingVersion) { if (["next", "stable", "latest"].includes(autoUpdatingVersion)) { return true; } const autoUpdatingVersionPinnedVersion = coerce(autoUpdatingVersion); if (autoUpdatingVersionPinnedVersion) { version = autoUpdatingVersionPinnedVersion; } } return isGreater(version, StudioApplication.MinimumStudioVersion); } /** * Returns a list of issues that prevent the studio from functioning properly in the Dashboard. * This static value depends on the version of the studio that comes from the `activeDeployment` property. * As such, if the studio is auto-updating, this list will be incorrect & instead you should use * the static method `resolveIssues` to get the correct issues by passing the resolved version. */ get issues(): StudioIssues { return StudioApplication.resolveIssues(this); } static resolveIssues( application: StudioApplication, version: string | null = application.version, ): StudioIssues { const issues: StudioIssues = StudioApplication.Features.filter( (feature) => { return !application.isFeatureSupported(feature.id, version); }, ); if (!application.hasManifest) { issues.push({ id: StudioApplication.StudioIssues.ISSUE_MANIFEST, }); } return issues; } protected isFeatureSupported( feature: StudioDashboardIssue, version = this.version, ) { const featureVersion = StudioApplication.Features.find( (_) => _.id === feature, )?.version; if (!featureVersion || !version) { return false; } return isGreaterOrEqual(version, featureVersion); } static CompatibilityStatuses = { UNKNOWN: "unknown", PARTIALLY_COMPATIBLE: "partially-compatible", FULLY_COMPATIBLE: "fully-compatible", } as const; static StudioIssues = { ISSUE_ACTIVITY: "ACTIVITY", ISSUE_AGENT: "AGENT", ISSUE_FAVORITES: "FAVORITES", ISSUE_URL_SYNCING: "URL_SYNCING", ISSUE_UI_ADJUSTMENT: "UI_ADJUSTMENT", ISSUE_CONTENT_MAPPING: "CONTENT_MAPPING", ISSUE_LOGIN: "LOGIN", ISSUE_MANIFEST: "MANIFEST", } as const; static Features = [ { id: StudioApplication.StudioIssues.ISSUE_AGENT, version: "5.1.0", }, { id: StudioApplication.StudioIssues.ISSUE_FAVORITES, version: "3.88.1", }, { id: StudioApplication.StudioIssues.ISSUE_ACTIVITY, version: "3.88.1", }, { id: StudioApplication.StudioIssues.ISSUE_URL_SYNCING, version: "3.75.0", }, { id: StudioApplication.StudioIssues.ISSUE_UI_ADJUSTMENT, version: "3.78.1", }, { id: StudioApplication.StudioIssues.ISSUE_CONTENT_MAPPING, version: "3.68.0", }, { id: StudioApplication.StudioIssues.ISSUE_LOGIN, version: "2.28.0", }, ] satisfies StudioIssues; static MinimumStudioVersion = "2.28.0" as const; static MinimumStudioVersionWithNoIssues = sortReversed( StudioApplication.Features.map((feature) => feature.version), ).at(0); } type CompatibilityStatus = (typeof StudioApplication.CompatibilityStatuses)[keyof typeof StudioApplication.CompatibilityStatuses]; type StudioDashboardIssue = (typeof StudioApplication.StudioIssues)[keyof typeof StudioApplication.StudioIssues]; type StudioIssues = Array<{ id: StudioDashboardIssue; version?: string; }>;