feat(knowledge): align GitHub preview in space creation

(cherry picked from commit 21367f10ce)
This commit is contained in:
FFXN committed 2026-11-10 18:45:59 +08:00
1 parent 9c0791c367
commit c19d01e759
8 files changed
+640 -5

No files matched your search

@@ -66,6 +66,13 @@ class KnowledgeFSInitialSourcePreviewService:
payload: KnowledgeFSInitialSourcePreviewPayload | KnowledgeFSInitialWebsiteSourcePreviewPayload,
is_canceled: Callable[[], bool] | None = None,
) -> KnowledgeFSInitialSourcePreviewResponse:
"""Preview a visible credential without creating a Space.
The caller supplies valid plugin parameters and an account in the tenant. Each
online-document invocation returns only the pages emitted for that request;
optional tree ancestry and cursor metadata remain intact. Previewing does not
persist a source or alter credentials, and plugin failures propagate to the caller.
"""
_raise_if_canceled(is_canceled)
self.require_visible_credential(tenant_id=tenant_id, account=account, payload=payload)
from extensions.application_services.data_sources import build_data_source_credentials
@@ -117,19 +124,24 @@ class KnowledgeFSInitialSourcePreviewService:
if payload.kind == "online_document":
document_runtime = cast(OnlineDocumentDatasourcePlugin, runtime)
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = []
next_cursor: str | None = None
for document_message in document_runtime.get_online_document_pages(
user_id=account.id,
datasource_parameters=parameters,
provider_type=document_runtime.datasource_provider_type(),
):
next_cursor = document_message.next_cursor or next_cursor
for workspace in document_message.result:
next_cursor = workspace.next_cursor or next_cursor
workspace_id = workspace.workspace_id or payload.provider
for document_page in workspace.pages:
documents.append(
KnowledgeFSInitialSourcePreviewDocumentResponse(
has_children=document_page.has_children,
last_edited_time=document_page.last_edited_time,
name=document_page.page_name,
page_id=document_page.page_id,
parent_id=document_page.parent_id,
provider_item_id=json.dumps(
[workspace_id, document_page.page_id], separators=(",", ":")
),
@@ -142,8 +154,11 @@ class KnowledgeFSInitialSourcePreviewService:
return KnowledgeFSInitialSourcePreviewResponse(
documents=documents,
kind=payload.kind,
next_cursor=next_cursor,
)
return KnowledgeFSInitialSourcePreviewResponse(documents=documents, kind=payload.kind)
return KnowledgeFSInitialSourcePreviewResponse(
documents=documents, kind=payload.kind, next_cursor=next_cursor
)
drive_runtime = cast(OnlineDriveDatasourcePlugin, runtime)
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = []
+3
View File
@@ -312,11 +312,13 @@ class KnowledgeFSInitialSourcePreviewPageResponse(ResponseModel):
class KnowledgeFSInitialSourcePreviewDocumentResponse(ResponseModel):
has_children: bool | None = Field(default=None, validation_alias=AliasChoices("has_children", "hasChildren"))
last_edited_time: str | None = Field(
default=None, validation_alias=AliasChoices("last_edited_time", "lastEditedTime")
)
name: str
page_id: str = Field(validation_alias=AliasChoices("page_id", "pageId"))
parent_id: str | None = Field(default=None, validation_alias=AliasChoices("parent_id", "parentId"))
provider_item_id: str = Field(validation_alias=AliasChoices("provider_item_id", "providerItemId"))
type: str
workspace_id: str = Field(validation_alias=AliasChoices("workspace_id", "workspaceId"))
@@ -341,6 +343,7 @@ class KnowledgeFSInitialSourcePreviewResponse(ResponseModel):
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = Field(default_factory=list)
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = Field(default_factory=list)
kind: Literal["online_document", "online_drive", "website_crawl"]
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
next_page_parameters: dict[str, JsonValue] | None = Field(
default=None,
validation_alias=AliasChoices("next_page_parameters", "nextPageParameters"),
@@ -164,6 +164,61 @@ def test_preview_lists_online_documents_with_stable_import_identity(
assert runtime.runtime.credentials == {"token": "secret"}
def test_preview_preserves_github_tree_branch_and_cursor(
sqlite_session_factory: sessionmaker[Session],
) -> None:
service = _service(sqlite_session_factory)
runtime = FakeDatasourceRuntime(
provider_type="online_document",
messages=[
OnlineDocumentPagesMessage(
next_cursor="2",
result=[
OnlineDocumentInfo(
next_cursor="2",
pages=[
OnlineDocumentPage(
has_children=True,
last_edited_time="2026-11-10T00:00:00Z",
page_id="dir:FFXN/dify:src",
page_name="src",
parent_id="contents:FFXN/dify",
type="github_directory",
)
],
total=1,
workspace_id="github-user-1",
workspace_name="FFXN's GitHub",
)
],
)
],
)
payload = _payload("online_document")
payload.parameters = {"view": "tree", "parent_id": "contents:FFXN/dify", "limit": 100}
with (
patch(
"services.data_source.provider_service.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "secret"},
),
patch(
"services.knowledge_fs.initial_source_preview.DatasourceManager.get_datasource_runtime",
return_value=runtime,
),
):
response = service.preview(
tenant_id="tenant-1",
account=_account(),
payload=payload,
)
assert runtime.online_document_calls[0]["datasource_parameters"] == payload.parameters
assert response.next_cursor == "2"
assert response.documents[0].has_children is True
assert response.documents[0].parent_id == "contents:FFXN/dify"
assert response.documents[0].provider_item_id == '["github-user-1","dir:FFXN/dify:src"]'
def test_preview_browses_online_drive_and_preserves_pagination(sqlite_session_factory: sessionmaker[Session]) -> None:
service = _service(sqlite_session_factory)
runtime = FakeDatasourceRuntime(
@@ -80,6 +80,7 @@ export type KnowledgeFsInitialSourcePreviewResponse = {
documents?: Array<KnowledgeFsInitialSourcePreviewDocumentResponse>
files?: Array<KnowledgeFsInitialSourcePreviewFileResponse>
kind: 'online_document' | 'online_drive' | 'website_crawl'
next_cursor?: string | null
next_page_parameters?: {
[key: string]: JsonValue
} | null
@@ -1535,9 +1536,11 @@ export type KnowledgeFsPublicFailureResponse = {
export type JsonValue = unknown
export type KnowledgeFsInitialSourcePreviewDocumentResponse = {
has_children?: boolean | null
last_edited_time?: string | null
name: string
page_id: string
parent_id?: string | null
provider_item_id: string
type: string
workspace_id: string
@@ -1240,9 +1240,11 @@ export const zKnowledgeFsInitialWebsiteSourcePreviewPayload = z.object({
* KnowledgeFSInitialSourcePreviewDocumentResponse
*/
export const zKnowledgeFsInitialSourcePreviewDocumentResponse = z.object({
has_children: z.boolean().nullish(),
last_edited_time: z.string().nullish(),
name: z.string(),
page_id: z.string(),
parent_id: z.string().nullish(),
provider_item_id: z.string(),
type: z.string(),
workspace_id: z.string(),
@@ -1280,6 +1282,7 @@ export const zKnowledgeFsInitialSourcePreviewResponse = z.object({
documents: z.array(zKnowledgeFsInitialSourcePreviewDocumentResponse).optional(),
files: z.array(zKnowledgeFsInitialSourcePreviewFileResponse).optional(),
kind: z.enum(['online_document', 'online_drive', 'website_crawl']),
next_cursor: z.string().nullish(),
next_page_parameters: z.record(z.string(), zJsonValue).nullish(),
pages: z.array(zKnowledgeFsInitialSourcePreviewPageResponse).optional(),
})
@@ -403,6 +403,48 @@ const notionDatasourceAuth = {
provider: 'notion',
}
const githubDatasourcePlugin = {
...notionDatasourcePlugin,
declaration: {
...notionDatasourcePlugin.declaration,
datasources: [
{
...notionDatasourcePlugin.declaration.datasources[0],
identity: {
...notionDatasourcePlugin.declaration.datasources[0]?.identity,
label: { en_US: 'GitHub Repository', zh_Hans: 'GitHub Repository' },
name: 'github',
provider: 'github',
},
},
],
identity: {
...notionDatasourcePlugin.declaration.identity,
label: { en_US: 'GitHub Repository', zh_Hans: 'GitHub Repository' },
name: 'github',
},
},
plugin_id: 'langgenius/github_datasource',
plugin_unique_identifier: 'langgenius/github_datasource:0.4.12@local',
provider: 'github',
}
const githubDatasourceAuth = {
...notionDatasourceAuth,
credentials_list: [
{
...notionDatasourceAuth.credentials_list[0],
id: 'github-credential-1',
name: 'GitHub token',
},
],
label: { en_US: 'GitHub Repository' },
name: 'github',
plugin_id: 'langgenius/github_datasource',
plugin_unique_identifier: 'langgenius/github_datasource:0.4.12@local',
provider: 'github',
}
const outlineDatasourcePlugin = {
...notionDatasourcePlugin,
declaration: {
@@ -2382,6 +2424,100 @@ describe('CreateKnowledgePage', () => {
})
})
it('previews GitHub branches lazily and imports only explicitly selected pages', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
datasourceQueryMock.plugins.data = [
firecrawlDatasourcePlugin,
notionDatasourcePlugin,
githubDatasourcePlugin,
]
datasourceQueryMock.auth.data = {
result: [firecrawlDatasourceAuth, notionDatasourceAuth, githubDatasourceAuth],
}
const page = (pageId: string, name: string, type: string, parentId?: string) => ({
has_children:
type === 'repository' || type === 'github_collection' || type === 'github_directory',
last_edited_time: '2026-11-10T00:00:00Z',
name,
page_id: pageId,
parent_id: parentId ?? null,
provider_item_id: JSON.stringify(['github-user-1', pageId]),
type,
workspace_id: 'github-user-1',
workspace_name: "FFXN's GitHub",
})
serviceMock.previewInitialSource.mockImplementation(
async ({ body }: { body: { parameters: Record<string, unknown> } }) => {
const { parent_id: parentId, cursor } = body.parameters
const documents =
parentId === 'repo:FFXN/dify'
? [page('contents:FFXN/dify', 'Contents', 'github_collection', parentId)]
: parentId === 'contents:FFXN/dify'
? [page('dir:FFXN/dify:src', 'src', 'github_directory', parentId)]
: parentId === 'dir:FFXN/dify:src'
? cursor
? [page('file:FFXN/dify:src/extra.ts', 'extra.ts', 'file', parentId)]
: [page('file:FFXN/dify:src/index.ts', 'index.ts', 'file', parentId)]
: [page('repo:FFXN/dify', 'dify', 'repository')]
return {
documents,
kind: 'online_document',
next_cursor: parentId === 'dir:FFXN/dify:src' && !cursor ? '100' : null,
}
},
)
await renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('radio', { name: 'knowledgeSpace.onlineDocuments' }))
await user.click(screen.getByRole('radio', { name: 'GitHub Repository' }))
await user.type(
screen.getByPlaceholderText('knowledgeSources.sourceNamePlaceholder'),
'GitHub code',
)
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.preview' }))
await user.click(await screen.findByRole('button', { name: 'dify' }))
await user.click(await screen.findByRole('button', { name: 'Contents' }))
await user.click(await screen.findByRole('button', { name: 'src' }))
await user.click(await screen.findByRole('checkbox', { name: 'src' }))
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.loadMore' }))
await screen.findByRole('checkbox', { name: 'extra.ts' })
expect(screen.getByRole('checkbox', { name: 'index.ts' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'extra.ts' })).not.toBeChecked()
expect(
serviceMock.previewInitialSource.mock.calls.map(([request]) => request.body.parameters),
).toEqual([
{ limit: 10, view: 'tree' },
{ limit: 30, parent_id: 'repo:FFXN/dify', view: 'tree' },
{ limit: 100, parent_id: 'contents:FFXN/dify', view: 'tree' },
{ limit: 100, parent_id: 'dir:FFXN/dify:src', view: 'tree' },
{ cursor: '100', limit: 100, parent_id: 'dir:FFXN/dify:src', view: 'tree' },
])
await user.click(screen.getByRole('button', { name: 'knowledgeCreate.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({
initial_source: expect.objectContaining({
datasource: 'github',
kind: 'online_document',
name: 'GitHub code',
parameters: {},
pluginId: 'langgenius/github_datasource',
selection: [
expect.objectContaining({
pageId: 'file:FFXN/dify:src/index.ts',
type: 'file',
}),
],
}),
}),
})
})
it('does not inject website defaults when switching connected document providers', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
@@ -25,6 +25,7 @@ import {
withDatasourceParameterDefaults,
} from './datasource-parameter-model'
import { SourceNameField, SourceSyncPolicyField } from './fields'
import { InitialGitHubTreePreview } from './initial-github-tree-preview'
type NextPageRequest = {
bucket?: string
@@ -160,6 +161,9 @@ function ConnectedSourceConfigurationFields({
// previews start a new session so pending root or pagination responses are discarded.
const previewSessionRef = useRef(0)
const driveTransport = providerOption.providerType === 'online_drive'
const githubTreePreview =
providerOption.plugin.plugin_id === 'langgenius/github_datasource' &&
providerOption.datasource.identity.name === 'github'
const parameterSchemas = useMemo(
() => datasourceParameterSchemas(providerOption.datasource),
[providerOption.datasource],
@@ -371,7 +375,17 @@ function ConnectedSourceConfigurationFields({
onDraftChange({ ...draft, parameters: nextParameters })
}}
/>
{!previewed && (
{githubTreePreview && (
<InitialGitHubTreePreview
key={JSON.stringify(parameters)}
binding={previewBinding}
disabled={disabled || !parametersValid}
parameters={parameters}
selectedResources={selectedResources}
onSelectionChange={onSelectionChange ?? setLocalSelection}
/>
)}
{!githubTreePreview && !previewed && (
<Button
type="button"
variant="primary"
@@ -383,12 +397,12 @@ function ConnectedSourceConfigurationFields({
{t(($) => $.preview)}
</Button>
)}
{loading && (
{!githubTreePreview && loading && (
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
<LoadingPlaceholder />
</div>
)}
{error && (
{!githubTreePreview && error && (
<div role="alert" className="rounded-lg bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{t(($) => $.providerLoadFailed)}</p>
<Button className="mt-3" type="button" onClick={() => void requestPreview()}>
@@ -396,7 +410,7 @@ function ConnectedSourceConfigurationFields({
</Button>
</div>
)}
{previewed && !loading && (
{!githubTreePreview && previewed && !loading && (
<section className="overflow-hidden rounded-lg border border-divider-subtle bg-background-default">
<div className="flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
<Checkbox
@@ -0,0 +1,406 @@
'use client'
import type {
ConnectedSourceConfigurationBinding,
ConnectedSourceSelection,
PreviewResource,
} from './connected-source-selection'
import type { DatasourceParameters } from './datasource-parameter-model'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { consoleClient } from '@/service/console'
type DocumentResource = Extract<PreviewResource, { kind: 'document' }>
type Branch = {
error: boolean
loaded: boolean
loading: boolean
nextCursor: string | null
requestedCursors: string[]
resources: DocumentResource[]
}
type Row =
| { kind: 'document'; resource: DocumentResource }
| { kind: 'loadMore'; depth: number; parentKey: string }
const ROOT = 'root'
const ROOT_PAGE_SIZE = 10
const CHILD_PAGE_SIZE = 30
const DIRECTORY_PAGE_SIZE = 100
const MAX_SELECTION = 200
const SELECTION_LIMIT_ID = 'create-github-source-selection-limit'
function isNavigationOnly(resource: DocumentResource) {
return (
resource.document.type === 'github_collection' || resource.document.type === 'github_directory'
)
}
function resourceIcon(resource: DocumentResource) {
return resource.document.type === 'repository' || isNavigationOnly(resource)
? 'i-ri-folder-3-fill text-text-warning'
: 'i-ri-file-text-line text-text-tertiary'
}
export function InitialGitHubTreePreview({
binding,
disabled,
parameters,
selectedResources,
onSelectionChange,
}: {
binding: ConnectedSourceConfigurationBinding
disabled: boolean
parameters: DatasourceParameters
selectedResources: ConnectedSourceSelection
onSelectionChange: (selection: ConnectedSourceSelection) => void
}) {
const { t } = useTranslation(['knowledgeSpace', 'knowledgeSources', 'common'])
const [branches, setBranches] = useState<ReadonlyMap<string, Branch>>(() => new Map())
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set())
const root = branches.get(ROOT)
const allResources = [
...new Map(
[...branches.values()]
.flatMap((branch) => branch.resources)
.map((resource) => [resource.key, resource]),
).values(),
]
const selectable = allResources.filter((resource) => !isNavigationOnly(resource))
const selected = new Set(selectedResources.map((resource) => resource.key))
const selectionAtLimit = selected.size >= MAX_SELECTION
const updateSelection = (keys: Set<string>) => {
onSelectionChange(allResources.filter((resource) => keys.has(resource.key)))
}
const loadBranch = async (parent?: DocumentResource, cursor?: string) => {
const scope = parent?.key ?? ROOT
const prior = branches.get(scope)
if (prior?.loading || (cursor && prior?.requestedCursors.includes(cursor))) return
if (!parent && !cursor) {
setExpanded(new Set())
onSelectionChange([])
}
setBranches((current) =>
new Map(current).set(scope, {
error: false,
loaded: prior?.loaded ?? false,
loading: true,
nextCursor: prior?.nextCursor ?? null,
requestedCursors: prior?.requestedCursors ?? [],
resources: prior?.resources ?? [],
}),
)
try {
const response = await consoleClient.knowledgeFs.sourceProviderPreview.post({
body: {
credentialId: binding.credentialId,
datasource: binding.datasource,
kind: 'online_document',
parameters: {
...parameters,
view: 'tree',
limit: parent
? parent.document.type === 'github_directory' ||
parent.document.page_id.startsWith('contents:')
? DIRECTORY_PAGE_SIZE
: CHILD_PAGE_SIZE
: ROOT_PAGE_SIZE,
...(parent ? { parent_id: parent.document.page_id } : {}),
...(cursor ? { cursor } : {}),
},
pluginId: binding.pluginId,
provider: binding.provider,
},
})
const resources: DocumentResource[] = (response.documents ?? []).map((document) => ({
depth: parent ? parent.depth + 1 : 0,
document,
key: `document:${document.provider_item_id}`,
kind: 'document',
parentKey: parent?.key,
}))
setBranches((current) => {
const existing = current.get(scope)
const merged = new Map(
(cursor ? (existing?.resources ?? []) : []).map((item) => [item.key, item]),
)
for (const resource of resources) merged.set(resource.key, resource)
const requestedCursors = cursor ? [...(existing?.requestedCursors ?? []), cursor] : []
const nextCursor = response.next_cursor
return new Map(current).set(scope, {
error: false,
loaded: true,
loading: false,
nextCursor: nextCursor && !requestedCursors.includes(nextCursor) ? nextCursor : null,
requestedCursors,
resources: [...merged.values()],
})
})
} catch {
setBranches((current) => {
const existing = current.get(scope)
return new Map(current).set(scope, {
error: true,
loaded: existing?.loaded ?? false,
loading: false,
nextCursor: existing?.nextCursor ?? null,
requestedCursors: existing?.requestedCursors ?? [],
resources: existing?.resources ?? [],
})
})
}
}
const loadedDescendants = (resource: DocumentResource) => {
const result: DocumentResource[] = []
const pending = [...(branches.get(resource.key)?.resources ?? [])]
const seen = new Set<string>()
while (pending.length) {
const child = pending.shift()
if (!child || seen.has(child.key)) continue
seen.add(child.key)
result.push(child)
pending.push(...(branches.get(child.key)?.resources ?? []))
}
return result.filter((child) => !isNavigationOnly(child))
}
const toggle = (resource: DocumentResource) => {
const targets = [
...(!isNavigationOnly(resource) ? [resource] : []),
...loadedDescendants(resource),
]
if (!targets.length) return
const next = new Set(selected)
if (targets.every((target) => next.has(target.key))) {
for (const target of targets) next.delete(target.key)
} else {
for (const target of targets) {
if (next.size >= MAX_SELECTION) break
next.add(target.key)
}
}
updateSelection(next)
}
const toggleAll = () => {
const next = new Set(selected)
if (selectable.length && selectable.every((resource) => next.has(resource.key))) {
for (const resource of selectable) next.delete(resource.key)
} else {
for (const resource of selectable) {
if (next.size >= MAX_SELECTION) break
next.add(resource.key)
}
}
updateSelection(next)
}
const toggleExpanded = (resource: DocumentResource) => {
const opening = !expanded.has(resource.key)
setExpanded((current) => {
const next = new Set(current)
if (opening) next.add(resource.key)
else next.delete(resource.key)
return next
})
if (opening && !branches.get(resource.key)?.loaded && !branches.get(resource.key)?.loading)
void loadBranch(resource)
}
const rows: Row[] = []
const appendRows = (scope: string, depth: number) => {
const branch = branches.get(scope)
for (const resource of branch?.resources ?? []) {
rows.push({ kind: 'document', resource })
if (expanded.has(resource.key)) appendRows(resource.key, depth + 1)
}
if (scope !== ROOT && branch?.nextCursor && !branch.loading && !branch.error)
rows.push({ kind: 'loadMore', depth, parentKey: scope })
}
appendRows(ROOT, 0)
const allSelected =
selectable.length > 0 && selectable.every((resource) => selected.has(resource.key))
const providerName = root?.resources[0]?.document.workspace_name || binding.providerDisplayName
return (
<>
{!root?.loaded && !root?.loading && !root?.error && (
<Button
type="button"
variant="primary"
className="w-full"
disabled={disabled}
onClick={() => void loadBranch()}
>
{t(($) => $.preview)}
</Button>
)}
{root?.loading && !root.loaded && (
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
<LoadingPlaceholder />
</div>
)}
{root?.error && !root.loaded && (
<div role="alert" className="rounded-lg bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{t(($) => $.providerLoadFailed)}</p>
<Button
className="mt-3"
type="button"
disabled={disabled}
onClick={() => void loadBranch()}
>
{t(($) => $.retryProviderLoad)}
</Button>
</div>
)}
{root?.loaded && (
<section className="flex h-78 flex-col overflow-hidden rounded-xl border border-divider-regular">
<div className="flex min-h-7.5 shrink-0 items-center gap-2 border-b border-divider-subtle px-3 py-1">
<Checkbox
aria-label={t(($) => $.selectAll, { ns: 'knowledgeSources' })}
aria-describedby={selectionAtLimit && !allSelected ? SELECTION_LIMIT_ID : undefined}
checked={allSelected}
disabled={disabled || !selectable.length || (selectionAtLimit && !allSelected)}
indeterminate={!allSelected && selected.size > 0}
onCheckedChange={toggleAll}
/>
<span className="system-xs-medium text-text-primary">
{t(($) => $.selectAll, { ns: 'knowledgeSources' })}
</span>
<span className="ms-auto min-w-0 flex-1 text-end system-xs-regular break-words text-text-tertiary">
{providerName}
</span>
</div>
<ul className="min-h-0 flex-1 overflow-y-auto py-1" aria-live="polite">
{rows.map((row) => {
if (row.kind === 'loadMore') {
const parent = allResources.find((resource) => resource.key === row.parentKey)
return (
<li
key={`load-more:${row.parentKey}`}
className="flex h-8.5 items-center pe-3"
style={{ paddingInlineStart: `${12 + row.depth * 26}px` }}
>
<Button
type="button"
size="small"
variant="ghost"
disabled={disabled}
onClick={() => {
if (parent)
void loadBranch(parent, branches.get(parent.key)?.nextCursor ?? undefined)
}}
>
{t(($) => $.loadMore)}
</Button>
</li>
)
}
const { resource } = row
const branch = branches.get(resource.key)
const targets = [
...(!isNavigationOnly(resource) ? [resource] : []),
...loadedDescendants(resource),
]
const checked =
targets.length > 0 && targets.every((target) => selected.has(target.key))
const indeterminate = !checked && targets.some((target) => selected.has(target.key))
const expandable = Boolean(resource.document.has_children)
const isExpanded = expanded.has(resource.key)
return (
<li
key={resource.key}
className={`flex items-center gap-2 pe-3 ${expandable ? 'h-8.5' : 'h-7.5'}`}
style={{ paddingInlineStart: `${12 + resource.depth * 26}px` }}
>
{expandable ? (
<button
type="button"
aria-label={resource.document.name}
aria-expanded={isExpanded}
className="flex size-5 items-center justify-center rounded text-text-tertiary hover:bg-state-base-hover"
disabled={disabled}
onClick={() => toggleExpanded(resource)}
>
<span
aria-hidden
className={`i-ri-arrow-right-s-line size-4 transition-transform motion-reduce:transition-none ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
) : (
<span className="size-5" />
)}
<Checkbox
aria-label={resource.document.name}
aria-describedby={selectionAtLimit && !checked ? SELECTION_LIMIT_ID : undefined}
checked={checked}
indeterminate={indeterminate}
disabled={disabled || !targets.length || (selectionAtLimit && !checked)}
onCheckedChange={() => toggle(resource)}
/>
<span aria-hidden className={`${resourceIcon(resource)} size-4 shrink-0`} />
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-primary">
{resource.document.name}
</span>
{isExpanded && branch?.loading && (
<span
role="status"
aria-label={t(($) => $.loading, { ns: 'common' })}
className="i-ri-loader-4-line size-4 animate-spin text-text-tertiary"
/>
)}
{isExpanded && branch?.error && (
<Button
type="button"
size="small"
variant="ghost"
disabled={disabled}
onClick={() =>
void loadBranch(
resource,
branch.loaded ? (branch.nextCursor ?? undefined) : undefined,
)
}
>
{t(($) => $.retryProviderLoad)}
</Button>
)}
</li>
)
})}
</ul>
{root.nextCursor && !root.loading && (
<div className="border-t border-divider-subtle px-3 py-2 text-center">
<Button
type="button"
size="small"
variant="ghost"
disabled={disabled}
onClick={() => void loadBranch(undefined, root.nextCursor ?? undefined)}
>
{t(($) => (root.error ? $.retryProviderLoad : $.loadMore))}
</Button>
</div>
)}
</section>
)}
{root?.loaded && (
<span role="status" className="system-xs-regular text-text-tertiary">
{t(($) => $.pagesSelected, { ns: 'knowledgeSources', count: selected.size })}
{selectionAtLimit && (
<span id={SELECTION_LIMIT_ID} className="ms-2 text-text-destructive">
{t(($) => $.maxPages, { ns: 'knowledgeSources' })}: {MAX_SELECTION}
</span>
)}
</span>
)}
</>
)
}