mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
* perf: shrink the module graph that gates first paint in dev * docs: drop the stale synchronous-icons claim on the import card * fix: replay search opened before its modal loads, guard lazy icons * fix: only intercept search before load where the modal mounts * perf: mount app-shell modals on first open and keep monaco off the shell * perf: load the icon map on first read, not at module evaluation * fix: report stale chunks with a reload toast, guard the home page against monaco
244 lines
8.9 KiB
TypeScript
244 lines
8.9 KiB
TypeScript
import type { Component } from 'svelte'
|
|
import { appIconComponent } from '$lib/components/icons/appIcon.svelte'
|
|
import { HubPublishService, SettingService } from '$lib/gen'
|
|
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
|
|
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
|
|
|
|
/**
|
|
* The browser-reachable hub. `hub_accessible_url` exists precisely for this: on a
|
|
* private instance `hub_base_url` may be an address only the server can resolve.
|
|
* The (logged) layout does the same lookup, but the import wizard renders outside
|
|
* it, so it has to ask for itself.
|
|
*/
|
|
export async function hubBrowserUrl(): Promise<string> {
|
|
try {
|
|
const accessible = (await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string
|
|
if (accessible) return accessible.replace(/\/+$/, '')
|
|
const base = (await SettingService.getGlobal({ key: 'hub_base_url' })) as string
|
|
if (base) return base.replace(/\/+$/, '')
|
|
} catch {
|
|
// Unset or unreadable — the public hub is the right default either way.
|
|
}
|
|
return DEFAULT_HUB_BASE_URL.replace(/\/+$/, '')
|
|
}
|
|
|
|
/** Shape of `GET <hub>/projects/<slug>` — the hub's own summary endpoint. */
|
|
interface HubProject {
|
|
slug: string
|
|
name: string
|
|
summary: string
|
|
author: string
|
|
apps: string[]
|
|
logoApp: string | null
|
|
hasLogo: boolean
|
|
counts: { scripts: number; flows: number; apps: number; resources: number; total: number }
|
|
}
|
|
|
|
/**
|
|
* Fetches one project's presentation straight from the hub. Cross-origin and
|
|
* unauthenticated by design: this runs before the wizard has a workspace, so the
|
|
* workspace-scoped `/api/w/<ws>/hub/...` proxy is not available yet. The endpoint
|
|
* is public and sends `Access-Control-Allow-Origin: *`.
|
|
*/
|
|
export async function fetchHubProject(slug: string): Promise<ImportProjectSummary> {
|
|
const hub = await hubBrowserUrl()
|
|
const res = await fetch(`${hub}/projects/${encodeURIComponent(slug)}`, {
|
|
headers: { accept: 'application/json' }
|
|
})
|
|
if (!res.ok) throw new Error(`hub returned ${res.status}`)
|
|
const p = (await res.json()) as HubProject
|
|
return {
|
|
slug: p.slug,
|
|
name: p.name,
|
|
summary: p.summary,
|
|
author: p.author,
|
|
apps: p.apps ?? [],
|
|
// A project with an uploaded logo shows that; otherwise the icon of the
|
|
// integration it is filed under, otherwise its first integration.
|
|
logoUrl: p.hasLogo ? `${hub}/projects/${encodeURIComponent(p.slug)}/logo` : undefined,
|
|
iconApps: [p.logoApp, ...(p.apps ?? [])].filter(
|
|
(a, i, all): a is string => !!a && all.indexOf(a) === i
|
|
),
|
|
counts: {
|
|
apps: p.counts?.apps ?? 0,
|
|
flows: p.counts?.flows ?? 0,
|
|
scripts: p.counts?.scripts ?? 0,
|
|
resources: p.counts?.resources ?? 0
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The icon for a hub integration slug, resolved from the icons Windmill already bundles.
|
|
*
|
|
* Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which
|
|
* is this frontend's own package, so asking it over HTTP is a round trip to get our own
|
|
* assets back — and it made the card depend on a cross-origin request that an `API_SECRET`
|
|
* hub refuses anyway.
|
|
*
|
|
* The alias exists because the two repos disagree on one slug: the hub files Postgres scripts
|
|
* under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in
|
|
* `aliasApp`; this is the same bridge on the consuming side.
|
|
*/
|
|
const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
|
|
|
|
export function hubAppIcon(app: string): Component | undefined {
|
|
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
|
|
}
|
|
|
|
/** One row of the hub's catalogue (`GET <hub>/projects`), which carries no item counts. */
|
|
interface HubProjectListRow {
|
|
slug: string
|
|
name: string
|
|
summary: string
|
|
description: string
|
|
readme: string
|
|
author: string
|
|
apps: string[]
|
|
hasLogo: boolean
|
|
stars: number
|
|
}
|
|
|
|
const DESCRIPTION_MAX = 320
|
|
|
|
/**
|
|
* What a project says about itself, in prose.
|
|
*
|
|
* The hub's `description` field is empty on every published project — the writing all
|
|
* goes in the readme — so the readme's opening paragraphs stand in. Everything from the
|
|
* first heading onwards is dropped: that is the "Windmill concepts demonstrated" /
|
|
* "Usage" material, which is documentation rather than a description. A readme that
|
|
* *starts* with a heading (`## Description`) has it skipped rather than treated as the
|
|
* end of the intro.
|
|
*/
|
|
export function hubProjectDescription(row: {
|
|
description?: string
|
|
readme?: string
|
|
summary?: string
|
|
}): string {
|
|
if (row.description?.trim()) return row.description.trim()
|
|
|
|
const lines = (row.readme ?? '').split('\n')
|
|
let i = 0
|
|
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++
|
|
const intro: string[] = []
|
|
for (; i < lines.length; i++) {
|
|
if (lines[i].startsWith('#')) break
|
|
intro.push(lines[i])
|
|
}
|
|
|
|
const text = intro
|
|
.join(' ')
|
|
// Inline markdown only — the block syntax is already gone with the headings.
|
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
.replace(/[*_`]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
if (!text) return row.summary?.trim() ?? ''
|
|
if (text.length <= DESCRIPTION_MAX) return text
|
|
// Cut on a word boundary: a description sliced mid-word reads as corrupted rather
|
|
// than shortened.
|
|
const cut = text.slice(0, DESCRIPTION_MAX)
|
|
const lastSpace = cut.lastIndexOf(' ')
|
|
return `${(lastSpace > DESCRIPTION_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`
|
|
}
|
|
|
|
/**
|
|
* A card in the template picker. Everything it shows comes from the catalogue listing,
|
|
* so a whole page of cards costs one request; the item counts, which only the import
|
|
* step needs, are fetched per project by `fetchHubProject` when one is picked.
|
|
* `id` is what `InfiniteList` dedupes rows by.
|
|
*/
|
|
export interface HubProjectPick {
|
|
id: string
|
|
slug: string
|
|
name: string
|
|
summary: string
|
|
description: string
|
|
author: string
|
|
apps: string[]
|
|
logoUrl?: string
|
|
iconApps: string[]
|
|
stars: number
|
|
}
|
|
|
|
let catalogue: { workspace: string; projects: Promise<HubProjectPick[]> } | undefined
|
|
|
|
/**
|
|
* Every published project, most-starred first, fetched once per workspace and held for
|
|
* the life of the page.
|
|
*
|
|
* Through the workspace-scoped proxy rather than straight at the hub the way
|
|
* `fetchHubProject` goes: the catalogue endpoint sends no `Access-Control-Allow-Origin`,
|
|
* so the browser cannot read it directly.
|
|
*/
|
|
export function hubProjectCatalogue(workspace: string): Promise<HubProjectPick[]> {
|
|
if (catalogue?.workspace !== workspace) {
|
|
const projects = loadCatalogue(workspace).catch((e) => {
|
|
// A cached rejection would make the failure permanent for the whole session;
|
|
// dropping it lets the next open try again.
|
|
if (catalogue?.projects === projects) catalogue = undefined
|
|
throw e
|
|
})
|
|
catalogue = { workspace, projects }
|
|
}
|
|
return catalogue.projects
|
|
}
|
|
|
|
async function loadCatalogue(workspace: string): Promise<HubProjectPick[]> {
|
|
const raw = await HubPublishService.listHubProjects({ workspace })
|
|
const rows = ((typeof raw === 'string' ? JSON.parse(raw) : raw)?.projects ??
|
|
[]) as HubProjectListRow[]
|
|
const hub = await hubBrowserUrl()
|
|
return rows
|
|
.map((row) => ({
|
|
id: row.slug,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
summary: row.summary,
|
|
description: hubProjectDescription(row),
|
|
author: row.author,
|
|
apps: row.apps ?? [],
|
|
logoUrl: row.hasLogo ? `${hub}/projects/${encodeURIComponent(row.slug)}/logo` : undefined,
|
|
iconApps: row.apps ?? [],
|
|
stars: row.stars ?? 0
|
|
}))
|
|
.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))
|
|
}
|
|
|
|
/** How many tool-matched projects to hoist when the invite named tools but no projects. */
|
|
const TOOL_PICKS_MAX = 3
|
|
|
|
/**
|
|
* The catalogue with the projects an invite picked for this person moved to the front, in
|
|
* the invite's order, each flagged so the row can say why it is there. Named slugs win; when
|
|
* none are named but the person's tools are known, the most-starred projects built on one
|
|
* of those tools stand in. Anything the hub no longer lists is skipped, so a stale invite
|
|
* costs nothing. `pinned` is set on every row rather than left off the rest: the list
|
|
* renders from it, and a missing flag would read as "not pinned" only by accident.
|
|
*/
|
|
export function pinHubProjects(
|
|
all: HubProjectPick[],
|
|
picks: { hub_projects?: string[]; tools?: string[] } | null | undefined
|
|
): (HubProjectPick & { pinned: boolean })[] {
|
|
let pinnedSlugs: string[] = []
|
|
if (picks?.hub_projects?.length) {
|
|
const known = new Set(all.map((p) => p.slug))
|
|
pinnedSlugs = picks.hub_projects.filter((s) => known.has(s))
|
|
} else if (picks?.tools?.length) {
|
|
const tools = new Set(picks.tools)
|
|
pinnedSlugs = all
|
|
.filter((p) => p.apps.some((a) => tools.has(a.toLowerCase())))
|
|
.slice(0, TOOL_PICKS_MAX)
|
|
.map((p) => p.slug)
|
|
}
|
|
if (!pinnedSlugs.length) return all.map((p) => ({ ...p, pinned: false }))
|
|
const bySlug = new Map(all.map((p) => [p.slug, p]))
|
|
const pinned = pinnedSlugs.map((s) => ({ ...bySlug.get(s)!, pinned: true }))
|
|
const pinnedSet = new Set(pinnedSlugs)
|
|
return [
|
|
...pinned,
|
|
...all.filter((p) => !pinnedSet.has(p.slug)).map((p) => ({ ...p, pinned: false }))
|
|
]
|
|
}
|