From 4fae2fc4ac19fe6ca1cb9735ccef5a9d9b11b513 Mon Sep 17 00:00:00 2001 From: Spr_Aachen <2835946988@qq.com> Date: Fri, 27 Mar 2026 23:22:12 +0800 Subject: [PATCH 1/2] Add entryPath prop to handle path properly --- components/entry/entry-form.tsx | 24 ++++++++++++++++++- components/entry/entry.tsx | 3 +++ fields/core/file/edit-component.tsx | 36 ++++++++++++++++++++++------ fields/core/image/edit-component.tsx | 36 ++++++++++++++++++++++------ 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 5a981a9e6..a5570e5c7 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -102,6 +102,7 @@ type RenderFields = ( parentName?: string, registerBeforeSubmitHook?: RegisterBeforeSubmitHook, inheritedReadonly?: boolean, + entryPath?: string, ) => React.ReactNode[]; type NestedFieldProps = { @@ -112,6 +113,7 @@ type NestedFieldProps = { isOpen?: boolean; onToggleOpen?: () => void; index?: number; + entryPath?: string; }; const hasFieldPathError = (errors: unknown, fieldName: string): boolean => { @@ -222,6 +224,7 @@ const ListItemRow = memo(function ListItemRow({ onRequestRemove, onRemoveConfirm, onPendingRemoveChange, + entryPath, }: { id: string; field: FieldWithReadonlyMeta; @@ -236,6 +239,7 @@ const ListItemRow = memo(function ListItemRow({ onRequestRemove: (index: number) => void; onRemoveConfirm: (index: number) => void; onPendingRemoveChange: (index: number, open: boolean) => void; + entryPath?: string; }) { const isReadonly = Boolean(field.readonly); return ( @@ -250,6 +254,7 @@ const ListItemRow = memo(function ListItemRow({ isOpen={isOpen ?? defaultOpen} toggleOpen={() => onToggleOpen(index)} index={index} + entryPath={entryPath} /> {!isReadonly && ( @@ -298,11 +303,13 @@ const ListField = ({ fieldName, renderFields, registerBeforeSubmitHook, + entryPath, }: { field: FieldWithReadonlyMeta; fieldName: string; renderFields: RenderFields; registerBeforeSubmitHook?: RegisterBeforeSubmitHook; + entryPath?: string; }) => { const supportsItemCollapse = field.type === "object" || field.type === "block"; @@ -522,6 +529,7 @@ const ListField = ({ onRequestRemove={handleRequestRemove} onRemoveConfirm={handleRemoveConfirm} onPendingRemoveChange={handlePendingRemoveChange} + entryPath={entryPath} /> ))} @@ -559,6 +567,7 @@ const BlocksField = forwardRef( isOpen, onToggleOpen, index, + entryPath, } = props; const isCollapsible = !!( @@ -731,6 +740,7 @@ const BlocksField = forwardRef( fieldName, registerBeforeSubmitHook, isReadonly, + entryPath, ); return renderedElements; })() @@ -749,6 +759,7 @@ const BlocksField = forwardRef( renderFields={renderFields} registerBeforeSubmitHook={registerBeforeSubmitHook} showLabel={false} + entryPath={entryPath} /> )} @@ -785,6 +796,7 @@ const ObjectField = forwardRef( isOpen = true, onToggleOpen = () => {}, index, + entryPath, } = props; const isCollapsible = !!( @@ -843,6 +855,7 @@ const ObjectField = forwardRef( fieldName, registerBeforeSubmitHook, Boolean(field.readonly), + entryPath, )} @@ -862,6 +875,7 @@ const SingleField = ({ isOpen = true, toggleOpen = () => {}, index = 0, + entryPath, }: { field: FieldWithReadonlyMeta; fieldName: string; @@ -872,6 +886,7 @@ const SingleField = ({ isOpen?: boolean; toggleOpen?: () => void; index?: number; + entryPath?: string; }) => { const { control, @@ -981,6 +996,7 @@ const SingleField = ({ const sharedProps = { ...rhfManagedFieldProps, field, + entryPath, }; if (field.type === "rich-text") { return ( @@ -1013,6 +1029,7 @@ const EntryForm = ({ contentObject, onSubmit = () => {}, filePath, + entryPath, onDirtyChange, onChangeRegistered, }: { @@ -1020,6 +1037,7 @@ const EntryForm = ({ contentObject?: Record; onSubmit: (values: Record) => void; filePath?: React.ReactNode; + entryPath?: string; onDirtyChange?: (isDirty: boolean) => void; onChangeRegistered?: () => void; }) => { @@ -1063,7 +1081,9 @@ const EntryForm = ({ parentName?: string, registerBeforeSubmitHook?: RegisterBeforeSubmitHook, inheritedReadonly = false, + entryPathProp?: string, ): React.ReactNode[] => { + const currentEntryPath = entryPathProp ?? entryPath; return fields.map((field) => { if (!field || field.hidden) return null; const effectiveField = @@ -1086,6 +1106,7 @@ const EntryForm = ({ fieldName={currentFieldName} renderFields={renderFields} registerBeforeSubmitHook={registerBeforeSubmitHook} + entryPath={currentEntryPath} /> ); } @@ -1097,11 +1118,12 @@ const EntryForm = ({ renderFields={renderFields} registerBeforeSubmitHook={registerBeforeSubmitHook} onChangeRegistered={onChangeRegistered} + entryPath={currentEntryPath} /> ); }); }, - [onChangeRegistered], + [onChangeRegistered, entryPath], ); const runBeforeValidationHooks = useCallback(async () => { diff --git a/components/entry/entry.tsx b/components/entry/entry.tsx index 4ba8916d6..973bc94fa 100644 --- a/components/entry/entry.tsx +++ b/components/entry/entry.tsx @@ -702,6 +702,8 @@ export function Entry({ } } + const entryPath = path ? normalizePath(getParentPath(path)) : undefined; + return ( isLoading ? loadingSkeleton @@ -709,6 +711,7 @@ export function Entry({ fields={entryFields} contentObject={entryContentObject} onSubmit={onSubmit} + entryPath={entryPath} filePath={ showFilenameField ? diff --git a/fields/core/file/edit-component.tsx b/fields/core/file/edit-component.tsx index 9dcaa6995..287a1f523 100644 --- a/fields/core/file/edit-component.tsx +++ b/fields/core/file/edit-component.tsx @@ -6,7 +6,7 @@ import { MediaUpload } from "@/components/media/media-upload"; import { MediaDialog } from "@/components/media/media-dialog"; import { Upload, File, FileText, FileVideo, FileImage, FileAudio, FileArchive, FileCode, FileType, FileSpreadsheet, GripVertical, FolderOpen, ArrowUpRight, EllipsisVertical } from "lucide-react"; import { useConfig } from "@/contexts/config-context"; -import { getFileExtension, getFileName, extensionCategories, normalizeMediaPath, normalizePath } from "@/lib/utils/file"; +import { getFileExtension, getFileName, extensionCategories, normalizeMediaPath, normalizePath, joinPathSegments } from "@/lib/utils/file"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable'; @@ -41,6 +41,7 @@ type EditorProps = { value?: string | string[] | null; field: Field; onChange: (value: string | string[] | undefined) => void; + entryPath?: string; }; type FieldOptions = { @@ -52,12 +53,31 @@ type FieldOptions = { rename?: boolean; }; -const FileTeaser = ({ file, config, onRemove, getFileIcon }: { +const FileTeaser = ({ file, config, entryPath, onRemove, getFileIcon }: { file: string; config: Pick; + entryPath?: string; onRemove?: () => void; getFileIcon: (file: string) => React.ReactNode; }) => { + const resolvedPath = useMemo(() => { + if (!file) return ""; + if (file.startsWith("http://") || file.startsWith("https://")) { + return file; + } + const normalizedFile = normalizeMediaPath(file); + if (normalizedFile.startsWith("/")) { + return normalizedFile; + } + if (normalizedFile.startsWith("http://") || normalizedFile.startsWith("https://")) { + return normalizedFile; + } + if (entryPath) { + return joinPathSegments([entryPath, normalizedFile]); + } + return normalizedFile; + }, [file, entryPath]); + return ( <> ); }; const EditComponent = forwardRef((props: EditorProps, ref: React.Ref) => { - const { value, field, onChange } = props; + const { value, field, onChange, entryPath } = props; void ref; const { config } = useConfig(); if (!config) throw new Error("Configuration not found."); @@ -349,6 +370,7 @@ const EditComponent = forwardRef((props: EditorProps, ref: React.Ref handleRemove(file.id)} getFileIcon={getFileIcon} readonly={isReadonly} @@ -359,7 +381,7 @@ const EditComponent = forwardRef((props: EditorProps, ref: React.Ref ) : (
- handleRemove(files[0].id)} getFileIcon={getFileIcon} /> + handleRemove(files[0].id)} getFileIcon={getFileIcon} />
) )} diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index 727d37fe0..d95f88492 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -6,7 +6,7 @@ import { MediaUpload } from "@/components/media/media-upload"; import { MediaDialog } from "@/components/media/media-dialog"; import { Upload, FolderOpen, ArrowUpRight, EllipsisVertical } from "lucide-react"; import { useConfig } from "@/contexts/config-context"; -import { normalizeMediaPath, normalizePath } from "@/lib/utils/file"; +import { normalizeMediaPath, normalizePath, joinPathSegments } from "@/lib/utils/file"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable'; @@ -43,6 +43,7 @@ type EditorProps = { value?: string | string[] | null; field: Field; onChange: (value: string | string[]) => void; + entryPath?: string; }; type FieldOptions = { @@ -52,11 +53,30 @@ type FieldOptions = { rename?: boolean; }; -const ImageTeaser = ({ file, config, onRemove }: { +const ImageTeaser = ({ file, config, entryPath, onRemove }: { file: string; config: Pick; + entryPath?: string; onRemove?: () => void; }) => { + const resolvedPath = useMemo(() => { + if (!file) return ""; + if (file.startsWith("http://") || file.startsWith("https://")) { + return file; + } + const normalizedFile = normalizeMediaPath(file); + if (normalizedFile.startsWith("/")) { + return normalizedFile; + } + if (normalizedFile.startsWith("http://") || normalizedFile.startsWith("https://")) { + return normalizedFile; + } + if (entryPath) { + return joinPathSegments([entryPath, normalizedFile]); + } + return normalizedFile; + }, [file, entryPath]); + return ( <>
); }; const EditComponent = forwardRef((props: EditorProps, ref: React.Ref) => { - const { value, field, onChange } = props; + const { value, field, onChange, entryPath } = props; void ref; const { config } = useConfig(); if (!config) throw new Error("Configuration not found."); @@ -304,6 +325,7 @@ const EditComponent = forwardRef((props: EditorProps, ref: React.Ref handleRemove(file.id)} readonly={isReadonly} /> @@ -316,7 +338,7 @@ const EditComponent = forwardRef((props: EditorProps, ref: React.Ref - handleRemove(files[0].id)} /> + handleRemove(files[0].id)} /> ) )} From 06b137b818c6e8c97f47f82be799b1060738ac58 Mon Sep 17 00:00:00 2001 From: Spr_Aachen <2835946988@qq.com> Date: Wed, 1 Apr 2026 18:18:02 +0800 Subject: [PATCH 2/2] Resolve conflicts with enhanced path handling --- .../[owner]/[repo]/[branch]/actions/page.tsx | 81 + app/(main)/[owner]/[repo]/[branch]/layout.tsx | 4 +- app/(main)/[owner]/[repo]/[branch]/page.tsx | 2 +- app/(main)/[owner]/[repo]/layout.tsx | 6 +- app/(main)/[owner]/[repo]/page.tsx | 8 +- app/(main)/page.tsx | 2 +- .../[repo]/[branch]/actions/[runId]/route.ts | 389 + .../[owner]/[repo]/[branch]/actions/route.ts | 488 + .../[owner]/[repo]/[branch]/cache/route.ts | 11 +- .../[branch]/collections/[name]/route.ts | 3 +- .../[branch]/entries/[path]/history/route.ts | 3 +- .../[repo]/[branch]/entries/[path]/route.ts | 3 +- .../[branch]/files/[path]/rename/route.ts | 31 +- .../[repo]/[branch]/files/[path]/route.ts | 56 +- app/api/webhook/github/route.ts | 37 +- app/error.tsx | 4 +- app/globals.css | 6 +- app/layout.tsx | 15 +- app/not-found.tsx | 2 +- components/actions/actions-page.tsx | 811 ++ components/cache/cache-page.tsx | 27 +- components/collaborators.tsx | 11 +- components/collection/collection.tsx | 1044 +- components/empty-create.tsx | 2 +- components/entry/entry-form.tsx | 90 +- components/entry/entry-history.tsx | 6 +- components/entry/entry.tsx | 171 +- components/media/media-upload.tsx | 13 +- components/media/media-view.tsx | 63 +- components/providers.tsx | 7 +- components/repo/repo-action-buttons.tsx | 497 + components/repo/repo-select.tsx | 2 +- components/repo/repo-sidebar.tsx | 31 + components/submit-button.tsx | 12 +- components/thumbnail.tsx | 4 +- components/ui/badge.tsx | 4 +- components/ui/checkbox.tsx | 32 + components/ui/hover-card.tsx | 44 + contexts/action-toast-context.tsx | 273 + db/migrations/0007_even_logan.sql | 12 + db/migrations/0008_quiet_lady_mastermind.sql | 29 + db/migrations/0009_cultured_odin.sql | 9 + db/migrations/meta/0007_snapshot.json | 1041 ++ db/migrations/meta/0008_snapshot.json | 1318 ++ db/migrations/meta/0009_snapshot.json | 1362 ++ db/migrations/meta/_journal.json | 21 + db/schema.ts | 50 +- fields/core/code/edit-component.css | 2 +- fields/core/code/edit-component.tsx | 11 +- fields/core/file/edit-component.tsx | 120 +- fields/core/image/edit-component.tsx | 113 +- fields/core/image/view-component.tsx | 6 +- fields/core/rich-text/edit-component.tsx | 334 +- fields/core/select/edit-component.tsx | 8 +- lib/cache-file-meta.ts | 203 +- lib/commit-message.ts | 16 +- lib/config-schema.ts | 160 + lib/github-cache.ts | 820 +- lib/github-image.ts | 152 +- lib/repo-actions.ts | 99 + lib/schema.ts | 58 +- lib/serialization.ts | 36 +- lib/token.ts | 66 +- lib/utils/file.ts | 34 +- package-lock.json | 11685 ++++++++-------- package.json | 4 +- types/api.ts | 13 + 67 files changed, 15020 insertions(+), 7057 deletions(-) create mode 100644 app/(main)/[owner]/[repo]/[branch]/actions/page.tsx create mode 100644 app/api/[owner]/[repo]/[branch]/actions/[runId]/route.ts create mode 100644 app/api/[owner]/[repo]/[branch]/actions/route.ts create mode 100644 components/actions/actions-page.tsx create mode 100644 components/repo/repo-action-buttons.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 contexts/action-toast-context.tsx create mode 100644 db/migrations/0007_even_logan.sql create mode 100644 db/migrations/0008_quiet_lady_mastermind.sql create mode 100644 db/migrations/0009_cultured_odin.sql create mode 100644 db/migrations/meta/0007_snapshot.json create mode 100644 db/migrations/meta/0008_snapshot.json create mode 100644 db/migrations/meta/0009_snapshot.json create mode 100644 lib/repo-actions.ts diff --git a/app/(main)/[owner]/[repo]/[branch]/actions/page.tsx b/app/(main)/[owner]/[repo]/[branch]/actions/page.tsx new file mode 100644 index 000000000..bd5825da2 --- /dev/null +++ b/app/(main)/[owner]/[repo]/[branch]/actions/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ActionsPage } from "@/components/actions/actions-page"; +import { DocumentTitle, formatRepoBranchTitle } from "@/components/document-title"; +import { useConfig } from "@/contexts/config-context"; +import { useUser } from "@/contexts/user-context"; +import { hasGithubIdentity } from "@/lib/authz"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { getRootActions, getSchemaActions } from "@/lib/repo-actions"; + +export default function Page() { + const { config } = useConfig(); + const { user } = useUser(); + + if (!config) throw new Error("Configuration not found."); + + if (!hasGithubIdentity(user)) { + return ( + + + Access denied + Only GitHub users can view action history. + + + ); + } + + const actionLabels = { + ...Object.fromEntries(getRootActions(config.object).map((action) => [action.name, action.label])), + ...Object.fromEntries( + ((config.object as any).content ?? []).flatMap((item: any) => + getSchemaActions(item).concat(getSchemaActions(item, "collection"), getSchemaActions(item, "entry")) + .map((action) => [action.name, action.label] as const), + ), + ), + ...Object.fromEntries( + ((config.object as any).media ?? []).flatMap((item: any) => + ((item.actions ?? []) as Array<{ name: string; label: string }>).map((action) => [action.name, action.label] as const), + ), + ), + }; + + const contextLabels = { + ...Object.fromEntries( + ((config.object as any).content ?? []).flatMap((item: any) => { + const label = item.label || item.name; + if (item.type === "collection") { + return [ + [`collection:${item.name}`, label] as const, + [`entry:${item.name}`, label] as const, + ]; + } + + return [[`file:${item.name}`, label] as const]; + }), + ), + ...Object.fromEntries( + ((config.object as any).media ?? []).map((item: any) => [ + `media:${item.name}`, + item.label || item.name, + ]), + ), + }; + + return ( + <> + +
+ +
+ + ); +} diff --git a/app/(main)/[owner]/[repo]/[branch]/layout.tsx b/app/(main)/[owner]/[repo]/[branch]/layout.tsx index 4fbb4de20..5ac0ca819 100644 --- a/app/(main)/[owner]/[repo]/[branch]/layout.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/layout.tsx @@ -67,7 +67,7 @@ export default async function Layout({ {`The branch "${decodedBranch}" could not be found. It may have been removed or renamed.`} - + Open default branch @@ -82,7 +82,7 @@ export default async function Layout({ You do not have permission to access this repository. - + Choose another repository diff --git a/app/(main)/[owner]/[repo]/[branch]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/page.tsx index ed12de476..824bdc228 100644 --- a/app/(main)/[owner]/[repo]/[branch]/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/page.tsx @@ -38,7 +38,7 @@ export default function Page() { Edit configuration on GitHub diff --git a/app/(main)/[owner]/[repo]/layout.tsx b/app/(main)/[owner]/[repo]/layout.tsx index 3d08688e3..4fa29a3e9 100644 --- a/app/(main)/[owner]/[repo]/layout.tsx +++ b/app/(main)/[owner]/[repo]/layout.tsx @@ -43,7 +43,7 @@ export default async function Layout({ Create a branch and add a ".pages.yml" file to configure this repository. - + Choose another repository @@ -71,7 +71,7 @@ export default async function Layout({ It may have been removed, renamed, or the URL may be incorrect. - + Choose another repository @@ -85,7 +85,7 @@ export default async function Layout({ You do not have permission to access this repository. - + Choose another repository diff --git a/app/(main)/[owner]/[repo]/page.tsx b/app/(main)/[owner]/[repo]/page.tsx index 4b37839b8..fc46654d4 100644 --- a/app/(main)/[owner]/[repo]/page.tsx +++ b/app/(main)/[owner]/[repo]/page.tsx @@ -3,16 +3,10 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useRepo } from "@/contexts/repo-context"; -import { useUser } from "@/contexts/user-context"; export default function Page() { const { owner, repo, defaultBranch } = useRepo(); const router = useRouter(); - const { user } = useUser(); - - if (!user) throw new Error("User not found"); - if (!user.accounts) throw new Error("Accounts not found"); - if (!user.accounts.find((account) => account.login.toLowerCase() === owner.toLowerCase())) throw new Error(`GitHub application not installed for "${owner}"`); useEffect(() => { // If no branch is provided, redirect to the default branch @@ -20,4 +14,4 @@ export default function Page() { }, [owner, repo, defaultBranch, router]); return null; -}; \ No newline at end of file +}; diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index 109773535..9245ae31a 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -74,7 +74,7 @@ export default function Page() {
- + new Promise((resolve) => setTimeout(resolve, ms)); + +const toSummary = ( + row: typeof actionRunTable.$inferSelect, + user: { id: string; githubUsername?: string | null }, +) => ({ + id: row.id, + actionName: row.actionName, + contextType: row.contextType, + contextName: row.contextName, + contextPath: row.contextPath, + workflowRef: row.workflowRef, + sha: row.sha, + status: row.status, + conclusion: row.conclusion, + htmlUrl: row.htmlUrl, + workflowRunId: row.workflowRunId, + triggeredByName: (row.triggeredBy as { name?: string | null } | null)?.name ?? null, + triggeredByEmail: (row.triggeredBy as { email?: string | null } | null)?.email ?? null, + triggeredByGithubUsername: (row.triggeredBy as { githubUsername?: string | null } | null)?.githubUsername ?? null, + triggeredByImage: (row.triggeredBy as { image?: string | null } | null)?.image ?? null, + canCancel: Boolean( + (hasGithubIdentity(user) + || (row.triggeredBy as { userId?: string | null } | null)?.userId === user.id) + && row.status !== "completed" + && row.workflowRunId + && ((row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true), + ), + canRerun: hasGithubIdentity(user), + cancelable: (row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, +}); + +const findWorkflowRun = async ( + octokit: ReturnType, + row: typeof actionRunTable.$inferSelect, + claimedRunIds: number[], +) => { + const startedAtMs = row.createdAt.getTime(); + const claimedRunIdsSet = new Set(claimedRunIds); + + for (let attempt = 0; attempt < 6; attempt++) { + const response = await octokit.rest.actions.listWorkflowRuns({ + owner: row.owner, + repo: row.repo, + workflow_id: row.workflow, + branch: row.workflowRef, + event: "workflow_dispatch", + per_page: 10, + }); + + const run = response.data.workflow_runs + .filter((item) => ( + Date.parse(item.created_at) >= startedAtMs - 30_000 + && !claimedRunIdsSet.has(item.id) + )) + .sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0]; + + if (run) return run; + await sleep(1500); + } + + return null; +}; + +const getClaimedWorkflowRunIds = async (row: typeof actionRunTable.$inferSelect) => { + const rows = await db.select({ + workflowRunId: actionRunTable.workflowRunId, + }).from(actionRunTable).where(and( + eq(actionRunTable.owner, row.owner), + eq(actionRunTable.repo, row.repo), + eq(actionRunTable.workflow, row.workflow), + eq(actionRunTable.workflowRef, row.workflowRef), + isNotNull(actionRunTable.workflowRunId), + ne(actionRunTable.id, row.id), + )); + + return rows + .map((claimedRow) => claimedRow.workflowRunId) + .filter((value): value is number => typeof value === "number"); +}; + +const resolveWorkflowSha = async ( + octokit: ReturnType, + owner: string, + repo: string, + workflowRef: string, +) => { + if (/^[a-f0-9]{40}$/i.test(workflowRef)) return workflowRef; + + try { + const branchResponse = await octokit.rest.repos.getBranch({ + owner, + repo, + branch: workflowRef, + }); + return branchResponse.data.commit.sha; + } catch {} + + try { + const headRefResponse = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${workflowRef}`, + }); + return headRefResponse.data.object.sha; + } catch {} + + const tagRefResponse = await octokit.rest.git.getRef({ + owner, + repo, + ref: `tags/${workflowRef}`, + }); + return tagRefResponse.data.object.sha; +}; + +export async function GET( + _request: Request, + context: { params: Promise<{ owner: string; repo: string; branch: string; runId: string }> }, +) { + try { + const params = await context.params; + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + + const runId = Number(params.runId); + if (!Number.isFinite(runId)) { + throw createHttpError("Invalid action run id.", 400); + } + + const [row] = await db.select().from(actionRunTable).where(and( + eq(actionRunTable.id, runId), + eq(actionRunTable.owner, params.owner), + eq(actionRunTable.repo, params.repo), + eq(actionRunTable.ref, params.branch), + )); + + if (!row) { + throw createHttpError("Action run not found.", 404); + } + + let syncedRow = row; + + if (row.status !== "completed") { + const { token } = await getToken(user, params.owner, params.repo, true); + const octokit = createOctokitInstance(token); + if (!row.workflowRunId) { + const workflowRun = await findWorkflowRun( + octokit, + row, + await getClaimedWorkflowRunIds(row), + ); + + if (workflowRun) { + const [updated] = await db.update(actionRunTable).set({ + workflowRunId: workflowRun.id, + status: workflowRun.status ?? "queued", + conclusion: workflowRun.conclusion, + htmlUrl: workflowRun.html_url, + updatedAt: new Date(), + completedAt: workflowRun.status === "completed" + ? new Date(workflowRun.updated_at) + : null, + }).where(eq(actionRunTable.id, row.id)).returning(); + + if (updated) syncedRow = updated; + } + } else { + const workflowRunResponse = await octokit.rest.actions.getWorkflowRun({ + owner: row.owner, + repo: row.repo, + run_id: row.workflowRunId, + }); + + const [updated] = await db.update(actionRunTable).set({ + status: workflowRunResponse.data.status ?? row.status, + conclusion: workflowRunResponse.data.conclusion, + htmlUrl: workflowRunResponse.data.html_url, + updatedAt: new Date(), + completedAt: workflowRunResponse.data.status === "completed" + ? new Date(workflowRunResponse.data.updated_at) + : null, + }).where(eq(actionRunTable.id, row.id)).returning(); + + if (updated) syncedRow = updated; + } + } + + return Response.json({ + status: "success", + message: "Action run fetched successfully.", + data: toSummary(syncedRow, user), + }); + } catch (error) { + console.error(error); + return toErrorResponse(error); + } +} + +export async function POST( + request: Request, + context: { params: Promise<{ owner: string; repo: string; branch: string; runId: string }> }, +) { + try { + const params = await context.params; + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + + const runId = Number(params.runId); + if (!Number.isFinite(runId)) { + throw createHttpError("Invalid action run id.", 400); + } + + const body = (await request.json()) as { intent?: "cancel" | "rerun" }; + if (body.intent !== "cancel" && body.intent !== "rerun") { + throw createHttpError("Invalid action intent.", 400); + } + + const [row] = await db.select().from(actionRunTable).where(and( + eq(actionRunTable.id, runId), + eq(actionRunTable.owner, params.owner), + eq(actionRunTable.repo, params.repo), + eq(actionRunTable.ref, params.branch), + )); + + if (!row) { + throw createHttpError("Action run not found.", 404); + } + + const { token } = await getToken(user, params.owner, params.repo, true); + const octokit = createOctokitInstance(token); + const isGithubUser = hasGithubIdentity(user); + const isOwnRun = (row.triggeredBy as { userId?: string | null } | null)?.userId === user.id; + const isCancelable = ((row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true); + + if (body.intent === "cancel") { + if (!isCancelable) { + throw createHttpError("This action cannot be cancelled.", 403); + } + if (!isGithubUser && !isOwnRun) { + throw createHttpError("You can only cancel your own action runs.", 403); + } + if (!row.workflowRunId) { + throw createHttpError("This action run cannot be cancelled yet.", 409); + } + + await octokit.rest.actions.cancelWorkflowRun({ + owner: row.owner, + repo: row.repo, + run_id: row.workflowRunId, + }); + + const [updated] = await db.update(actionRunTable).set({ + status: "completed", + conclusion: "cancelled", + updatedAt: new Date(), + completedAt: new Date(), + }).where(eq(actionRunTable.id, row.id)).returning(); + + return Response.json({ + status: "success", + message: "Action run cancelled successfully.", + data: updated ? toSummary(updated, user) : toSummary(row, user), + }); + } + + if (!isGithubUser) { + throw createHttpError("Only GitHub users can run this action again.", 403); + } + + const originalPayload = row.payload as { + action?: { name?: string; label?: string; cancelable?: boolean }; + repository?: { ref?: string; workflowRef?: string }; + context?: { type?: string; name?: string | null; path?: string | null; data?: Record }; + inputs?: Record; + } | null; + + const workflowRef = resolveActionRef(originalPayload?.repository?.workflowRef ?? row.workflowRef, params.branch); + const sha = await resolveWorkflowSha(octokit, params.owner, params.repo, workflowRef); + const timestamp = new Date(); + const payload = { + source: "pages-cms", + action: { + name: originalPayload?.action?.name ?? row.actionName, + label: originalPayload?.action?.label ?? row.actionName, + cancelable: originalPayload?.action?.cancelable ?? true, + }, + repository: { + owner: params.owner, + repo: params.repo, + ref: originalPayload?.repository?.ref ?? row.ref, + workflowRef, + sha, + }, + triggeredAt: timestamp.toISOString(), + triggerType: "rerun", + rerunOfActionRunId: row.id, + triggeredBy: { + userId: user.id, + name: user.name, + email: user.email, + githubUsername: user.githubUsername ?? null, + image: user.image ?? null, + }, + context: { + type: originalPayload?.context?.type ?? row.contextType, + name: originalPayload?.context?.name ?? row.contextName, + path: originalPayload?.context?.path ?? row.contextPath, + data: originalPayload?.context?.data ?? {}, + }, + inputs: originalPayload?.inputs ?? {}, + }; + + const [createdRun] = await db.insert(actionRunTable).values({ + owner: params.owner, + repo: params.repo, + ref: payload.repository.ref, + workflowRef, + sha, + actionName: payload.action.name, + contextType: payload.context.type, + contextName: payload.context.name, + contextPath: payload.context.path, + workflow: row.workflow, + status: "dispatching", + triggeredBy: payload.triggeredBy, + payload, + createdAt: timestamp, + updatedAt: timestamp, + }).returning(); + + await octokit.rest.actions.createWorkflowDispatch({ + owner: params.owner, + repo: params.repo, + workflow_id: row.workflow, + ref: workflowRef, + inputs: { + payload: JSON.stringify(payload), + }, + }); + + const workflowRun = await findWorkflowRun( + octokit, + createdRun, + await getClaimedWorkflowRunIds(createdRun), + ); + + if (workflowRun) { + await db.update(actionRunTable).set({ + workflowRunId: workflowRun.id, + status: workflowRun.status ?? "queued", + conclusion: workflowRun.conclusion, + htmlUrl: workflowRun.html_url, + updatedAt: new Date(), + completedAt: workflowRun.status === "completed" ? new Date(workflowRun.updated_at) : null, + }).where(eq(actionRunTable.id, createdRun.id)); + } else { + await db.update(actionRunTable).set({ + status: "queued", + updatedAt: new Date(), + }).where(eq(actionRunTable.id, createdRun.id)); + } + + return Response.json({ + status: "success", + message: "Action run started successfully.", + data: { + id: createdRun.id, + }, + }); + } catch (error) { + console.error(error); + return toErrorResponse(error); + } +} diff --git a/app/api/[owner]/[repo]/[branch]/actions/route.ts b/app/api/[owner]/[repo]/[branch]/actions/route.ts new file mode 100644 index 000000000..8088353d0 --- /dev/null +++ b/app/api/[owner]/[repo]/[branch]/actions/route.ts @@ -0,0 +1,488 @@ +import { and, desc, eq, inArray, isNull, isNotNull, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { actionRunTable } from "@/db/schema"; +import { createOctokitInstance } from "@/lib/utils/octokit"; +import { getToken } from "@/lib/token"; +import { createHttpError, toErrorResponse } from "@/lib/api-error"; +import { requireApiUserSession } from "@/lib/session-server"; +import { resolveActionRef } from "@/lib/repo-actions"; +import { hasGithubIdentity } from "@/lib/authz"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const resolveWorkflowSha = async ( + octokit: ReturnType, + owner: string, + repo: string, + workflowRef: string, +) => { + if (/^[a-f0-9]{40}$/i.test(workflowRef)) return workflowRef; + + try { + const branchResponse = await octokit.rest.repos.getBranch({ + owner, + repo, + branch: workflowRef, + }); + return branchResponse.data.commit.sha; + } catch {} + + try { + const headRefResponse = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${workflowRef}`, + }); + return headRefResponse.data.object.sha; + } catch {} + + const tagRefResponse = await octokit.rest.git.getRef({ + owner, + repo, + ref: `tags/${workflowRef}`, + }); + return tagRefResponse.data.object.sha; +}; + +const findWorkflowRun = async ( + octokit: ReturnType, + owner: string, + repo: string, + workflow: string, + workflowRef: string, + startedAt: string, + claimedRunIds: number[] = [], +) => { + const startedAtMs = Date.parse(startedAt); + const claimedRunIdsSet = new Set(claimedRunIds); + + for (let attempt = 0; attempt < 6; attempt++) { + const response = await octokit.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflow, + branch: workflowRef, + event: "workflow_dispatch", + per_page: 10, + }); + + const run = response.data.workflow_runs + .filter((item) => ( + Date.parse(item.created_at) >= startedAtMs - 30_000 + && !claimedRunIdsSet.has(item.id) + )) + .sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0]; + + if (run) return run; + await sleep(1500); + } + + return null; +}; + +const getClaimedWorkflowRunIds = async ( + owner: string, + repo: string, + workflow: string, + workflowRef: string, + excludeRowId?: number, +) => { + const rows = await db.select({ + workflowRunId: actionRunTable.workflowRunId, + }).from(actionRunTable).where(and( + eq(actionRunTable.owner, owner), + eq(actionRunTable.repo, repo), + eq(actionRunTable.workflow, workflow), + eq(actionRunTable.workflowRef, workflowRef), + isNotNull(actionRunTable.workflowRunId), + excludeRowId == null ? undefined : ne(actionRunTable.id, excludeRowId), + )); + + return rows + .map((row) => row.workflowRunId) + .filter((value): value is number => typeof value === "number"); +}; + +const buildContextWhere = ({ + owner, + repo, + ref, + actionNames, + contextType, + contextName, + contextPath, +}: { + owner: string; + repo: string; + ref: string; + actionNames: string[]; + contextType: string; + contextName: string | null; + contextPath: string | null; +}) => and( + eq(actionRunTable.owner, owner), + eq(actionRunTable.repo, repo), + eq(actionRunTable.ref, ref), + inArray(actionRunTable.actionName, actionNames), + eq(actionRunTable.contextType, contextType), + contextName == null ? isNull(actionRunTable.contextName) : eq(actionRunTable.contextName, contextName), + contextPath == null ? isNull(actionRunTable.contextPath) : eq(actionRunTable.contextPath, contextPath), +); + +const syncActionRun = async ( + octokit: ReturnType, + row: typeof actionRunTable.$inferSelect, +) => { + if (!row.workflowRunId) { + const claimedRunIds = await getClaimedWorkflowRunIds( + row.owner, + row.repo, + row.workflow, + row.workflowRef, + row.id, + ); + const workflowRun = await findWorkflowRun( + octokit, + row.owner, + row.repo, + row.workflow, + row.workflowRef, + row.createdAt.toISOString(), + claimedRunIds, + ); + + if (!workflowRun) return row; + + try { + const [updated] = await db.update(actionRunTable).set({ + workflowRunId: workflowRun.id, + status: workflowRun.status ?? "queued", + conclusion: workflowRun.conclusion, + htmlUrl: workflowRun.html_url, + updatedAt: new Date(), + completedAt: workflowRun.status === "completed" ? new Date(workflowRun.updated_at) : null, + }).where(eq(actionRunTable.id, row.id)).returning(); + + return updated ?? row; + } catch { + return row; + } + } + + if (row.status === "completed") { + return row; + } + + const workflowRunResponse = await octokit.rest.actions.getWorkflowRun({ + owner: row.owner, + repo: row.repo, + run_id: row.workflowRunId, + }); + + const [updated] = await db.update(actionRunTable).set({ + status: workflowRunResponse.data.status ?? row.status, + conclusion: workflowRunResponse.data.conclusion, + htmlUrl: workflowRunResponse.data.html_url, + updatedAt: new Date(), + completedAt: workflowRunResponse.data.status === "completed" + ? new Date(workflowRunResponse.data.updated_at) + : null, + }).where(eq(actionRunTable.id, row.id)).returning(); + + return updated ?? row; +}; + +export async function GET( + request: Request, + context: { params: Promise<{ owner: string; repo: string; branch: string }> }, +) { + try { + const params = await context.params; + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + const isGithubUser = hasGithubIdentity(user); + const { token } = await getToken(user, params.owner, params.repo, true); + const octokit = createOctokitInstance(token); + + const url = new URL(request.url); + const actionNames = (url.searchParams.get("actionNames") || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const contextType = url.searchParams.get("contextType") || ""; + const contextName = url.searchParams.get("contextName"); + const contextPath = url.searchParams.get("contextPath"); + + const listAll = url.searchParams.get("all") === "1"; + + if (!listAll && (actionNames.length === 0 || !contextType)) { + throw createHttpError("actionNames and contextType are required.", 400); + } + + const rows = await db.select().from(actionRunTable) + .where( + listAll + ? and( + eq(actionRunTable.owner, params.owner), + eq(actionRunTable.repo, params.repo), + eq(actionRunTable.ref, params.branch), + ) + : buildContextWhere({ + owner: params.owner, + repo: params.repo, + ref: params.branch, + actionNames, + contextType, + contextName, + contextPath, + }), + ) + .orderBy(desc(actionRunTable.createdAt)); + + if (listAll) { + const topRows = rows.slice(0, 100); + const syncedRows = await Promise.all(topRows.map((row) => syncActionRun(octokit, row))); + + return Response.json({ + status: "success", + message: "Action runs fetched successfully.", + data: syncedRows.map((row) => ({ + id: row.id, + actionName: row.actionName, + contextType: row.contextType, + contextName: row.contextName, + contextPath: row.contextPath, + workflowRef: row.workflowRef, + sha: row.sha, + status: row.status, + conclusion: row.conclusion, + htmlUrl: row.htmlUrl, + workflowRunId: row.workflowRunId, + triggeredByName: (row.triggeredBy as { name?: string | null } | null)?.name ?? null, + triggeredByEmail: (row.triggeredBy as { email?: string | null } | null)?.email ?? null, + triggeredByGithubUsername: (row.triggeredBy as { githubUsername?: string | null } | null)?.githubUsername ?? null, + triggeredByImage: (row.triggeredBy as { image?: string | null } | null)?.image ?? null, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + canCancel: Boolean( + (isGithubUser + || (row.triggeredBy as { userId?: string | null } | null)?.userId === user.id) + && row.status !== "completed" + && row.workflowRunId + && ((row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true), + ), + canRerun: isGithubUser, + cancelable: (row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true, + })), + }); + } + + const topRowsByAction = actionNames.reduce>((accumulator, actionName) => { + accumulator[actionName] = rows + .filter((row) => row.actionName === actionName) + .slice(0, 3); + return accumulator; + }, {}); + + const syncedTopRowsByAction = Object.fromEntries( + await Promise.all( + Object.entries(topRowsByAction).map(async ([actionName, actionRows]) => { + const syncedRows = await Promise.all( + actionRows.map((row) => syncActionRun(octokit, row)), + ); + return [actionName, syncedRows] as const; + }), + ), + ) as Record; + + return Response.json({ + status: "success", + message: "Action runs fetched successfully.", + data: actionNames.reduce>((accumulator, actionName) => { + accumulator[actionName] = (syncedTopRowsByAction[actionName] || []) + .map((row) => ({ + id: row.id, + actionName: row.actionName, + contextType: row.contextType, + contextName: row.contextName, + contextPath: row.contextPath, + workflowRef: row.workflowRef, + sha: row.sha, + status: row.status, + conclusion: row.conclusion, + htmlUrl: row.htmlUrl, + workflowRunId: row.workflowRunId, + triggeredByName: (row.triggeredBy as { name?: string | null } | null)?.name ?? null, + triggeredByEmail: (row.triggeredBy as { email?: string | null } | null)?.email ?? null, + triggeredByGithubUsername: (row.triggeredBy as { githubUsername?: string | null } | null)?.githubUsername ?? null, + triggeredByImage: (row.triggeredBy as { image?: string | null } | null)?.image ?? null, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + canCancel: Boolean( + (isGithubUser + || (row.triggeredBy as { userId?: string | null } | null)?.userId === user.id) + && row.status !== "completed" + && row.workflowRunId + && ((row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true), + ), + canRerun: isGithubUser, + cancelable: (row.payload as { action?: { cancelable?: boolean } } | null)?.action?.cancelable ?? true, + })); + return accumulator; + }, {}), + }); + } catch (error) { + console.error(error); + return toErrorResponse(error); + } +} + +export async function POST( + request: Request, + context: { params: Promise<{ owner: string; repo: string; branch: string }> }, +) { + try { + const params = await context.params; + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + + const { token } = await getToken(user, params.owner, params.repo, true); + const octokit = createOctokitInstance(token); + + const body = (await request.json()) as { + action?: { name?: string; label?: string; workflow?: string; ref?: string; cancelable?: boolean }; + context?: { + kind?: string; + name?: string | null; + path?: string | null; + data?: Record; + }; + inputs?: Record; + }; + + const action = body.action; + const actionContext = body.context; + if (!action?.name || !action?.label || !action?.workflow) { + throw createHttpError("Action name, label, and workflow are required.", 400); + } + if (!actionContext?.kind) { + throw createHttpError("Action context kind is required.", 400); + } + + const workflowRef = resolveActionRef(action.ref, params.branch); + const sha = await resolveWorkflowSha(octokit, params.owner, params.repo, workflowRef); + const timestamp = new Date(); + const payload = { + source: "pages-cms", + action: { + name: action.name, + label: action.label, + cancelable: action.cancelable !== false, + }, + repository: { + owner: params.owner, + repo: params.repo, + ref: params.branch, + workflowRef, + sha, + }, + triggeredAt: timestamp.toISOString(), + triggeredBy: { + userId: user.id, + name: user.name, + email: user.email, + githubUsername: user.githubUsername ?? null, + image: user.image ?? null, + }, + context: { + type: actionContext.kind, + name: actionContext.name ?? null, + path: actionContext.path ?? null, + data: actionContext.data ?? {}, + }, + inputs: body.inputs ?? {}, + }; + + const [createdRun] = await db.insert(actionRunTable).values({ + owner: params.owner, + repo: params.repo, + ref: params.branch, + workflowRef, + sha, + actionName: action.name, + contextType: actionContext.kind, + contextName: actionContext.name ?? null, + contextPath: actionContext.path ?? null, + workflow: action.workflow, + status: "dispatching", + triggeredBy: payload.triggeredBy, + payload, + createdAt: timestamp, + updatedAt: timestamp, + }).returning(); + + await octokit.rest.actions.createWorkflowDispatch({ + owner: params.owner, + repo: params.repo, + workflow_id: action.workflow, + ref: workflowRef, + inputs: { + payload: JSON.stringify(payload), + }, + }); + + const workflowRun = await findWorkflowRun( + octokit, + params.owner, + params.repo, + action.workflow, + workflowRef, + timestamp.toISOString(), + await getClaimedWorkflowRunIds( + params.owner, + params.repo, + action.workflow, + workflowRef, + createdRun.id, + ), + ); + + if (workflowRun) { + try { + await db.update(actionRunTable).set({ + workflowRunId: workflowRun.id, + status: workflowRun.status ?? "queued", + conclusion: workflowRun.conclusion, + htmlUrl: workflowRun.html_url, + updatedAt: new Date(), + completedAt: workflowRun.status === "completed" ? new Date(workflowRun.updated_at) : null, + }).where(eq(actionRunTable.id, createdRun.id)); + } catch { + await db.update(actionRunTable).set({ + status: "queued", + updatedAt: new Date(), + }).where(eq(actionRunTable.id, createdRun.id)); + } + } else { + await db.update(actionRunTable).set({ + status: "queued", + updatedAt: new Date(), + }).where(eq(actionRunTable.id, createdRun.id)); + } + + return Response.json({ + status: "success", + message: `Action "${action.label}" dispatched successfully.`, + data: { + id: createdRun.id, + }, + }); + } catch (error) { + console.error(error); + return toErrorResponse(error); + } +} diff --git a/app/api/[owner]/[repo]/[branch]/cache/route.ts b/app/api/[owner]/[repo]/[branch]/cache/route.ts index e2048f953..35d292cfa 100644 --- a/app/api/[owner]/[repo]/[branch]/cache/route.ts +++ b/app/api/[owner]/[repo]/[branch]/cache/route.ts @@ -7,7 +7,7 @@ import { clearPermissionCache, ensureFileCacheFreshness, } from "@/lib/github-cache"; -import { getCacheFileMeta, upsertCacheFileMeta } from "@/lib/cache-file-meta"; +import { deleteCacheFileMeta, getCacheFileMeta, listCacheFileMeta, upsertCacheFileMeta } from "@/lib/cache-file-meta"; import { getConfig } from "@/lib/utils/config"; import { createHttpError, toErrorResponse } from "@/lib/api-error"; import { isCacheEnabled } from "@/lib/config-settings"; @@ -46,6 +46,8 @@ export async function GET( // Keep DB access mostly sequential to avoid spiking pool usage on the cache dashboard. const meta = await getCacheFileMeta(params.owner, params.repo, params.branch); + const metaEntries = await listCacheFileMeta(params.owner, params.repo, params.branch); + const folderMeta = metaEntries.filter((entry) => entry.context !== "branch"); const fileCountResult = await db .select({ count: sql`count(*)` }) .from(cacheFileTable) @@ -78,6 +80,7 @@ export async function GET( status: "success", data: { fileMeta: meta ?? null, + folderMeta, fileCount: Number(fileCountResult[0]?.count || 0), permissionCount: Number(permissionCountResult[0]?.count || 0), config: cachedConfig @@ -140,8 +143,9 @@ export async function POST( }); case "clear-file-cache": await clearFileCache(params.owner, params.repo, params.branch); + await deleteCacheFileMeta(params.owner, params.repo, params.branch); await upsertCacheFileMeta(params.owner, params.repo, params.branch, { - sha: null, + commitSha: null, status: "ok", error: null, }); @@ -186,8 +190,9 @@ export async function POST( }); case "clear-all-cache": await clearFileCache(params.owner, params.repo, params.branch); + await deleteCacheFileMeta(params.owner, params.repo, params.branch); await upsertCacheFileMeta(params.owner, params.repo, params.branch, { - sha: null, + commitSha: null, status: "ok", error: null, }); diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index 8b9896dad..547a29abd 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -153,13 +153,14 @@ const parseContents = ( } => { const serializedTypes = ["yaml-frontmatter", "json-frontmatter", "toml-frontmatter", "yaml", "json", "toml"]; const excludedFiles = schema.exclude || []; + const extension = schema.extension ?? ""; let parsedContents: Record[] = []; let parsedErrors: string[] = []; parsedContents = contents.map((item: any) => { // If it's a file and it matches the schema extension - if (item.type === "file" && (item.path.endsWith(`.${schema.extension}`) || schema.extension === "") && !excludedFiles.includes(item.name)) { + if (item.type === "file" && (extension === "" || item.path.endsWith(`.${extension}`)) && !excludedFiles.includes(item.name)) { let contentObject: Record = {}; if (serializedTypes.includes(schema.format) && schema.fields) { diff --git a/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts b/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts index 22a4e178a..37707a10e 100644 --- a/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts +++ b/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts @@ -48,7 +48,8 @@ export async function GET( if (!normalizedPath.startsWith(schema.path)) throw createHttpError(`Invalid path "${params.path}" for ${schema.type} "${name}".`, 400); - if (getFileExtension(normalizedPath) !== schema.extension) { + const extension = schema.extension ?? ""; + if (getFileExtension(normalizedPath) !== extension) { throw createHttpError(`Invalid extension "${getFileExtension(normalizedPath)}" for ${schema.type} "${name}".`, 400); } } else if (normalizedPath !== ".pages.yml") { diff --git a/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts index 8c3ce3dfe..effd52f70 100644 --- a/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts @@ -76,7 +76,8 @@ export async function GET( if (!normalizedPath.startsWith(schema.path)) throw createHttpError(`Invalid path "${params.path}" for ${schema.type} "${name}".`, 400); - if (getFileExtension(normalizedPath) !== schema.extension) { + const extension = schema.extension ?? ""; + if (getFileExtension(normalizedPath) !== extension) { throw createHttpError(`Invalid extension "${getFileExtension(normalizedPath)}" for ${schema.type} "${name}".`, 400); } } else { diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts index 832ed75b0..ad8218fa5 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts @@ -6,7 +6,7 @@ import { getToken } from "@/lib/token"; import { updateFileCache } from "@/lib/github-cache"; import { toErrorResponse } from "@/lib/api-error"; import { getBranchHeadSha, setBranchHeadSha } from "@/lib/github-cache"; -import { buildCommitTokens, resolveCommitMessage } from "@/lib/commit-message"; +import { buildCommitTokens, resolveCommitIdentity, resolveCommitMessage } from "@/lib/commit-message"; import { requireApiUserSession } from "@/lib/session-server"; /** @@ -27,14 +27,8 @@ export async function POST( if ("response" in sessionResult) return sessionResult.response; const user = sessionResult.user; - const { token, source } = await getToken(user, params.owner, params.repo, true); + const { token } = await getToken(user, params.owner, params.repo, true); if (!token) throw new Error("Token not found"); - const committer = source === "installation" - ? { - name: user.name?.trim() || user.email, - email: user.email, - } - : undefined; if (params.path === ".pages.yml") throw new Error(`Renaming the settings file isn't allowed.`); @@ -55,6 +49,7 @@ export async function POST( let schema; let schemaCommitTemplates: Record | undefined; + let schemaCommitIdentity: "app" | "user" | undefined; switch (data.type) { case "content": @@ -63,14 +58,15 @@ export async function POST( schema = getSchemaByName(config.object, data.name); if (!schema) throw new Error(`Content schema not found for ${data.name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (schema.type === "file") throw new Error(`Renaming content of type "file" isn't allowed.`); if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${params.path}" for ${data.type} "${data.name}".`); if (!normalizedNewPath.startsWith(schema.path)) throw new Error(`Invalid path "${data.newPath}" for ${data.type} "${data.name}".`); - if (getFileExtension(normalizedPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${data.type} "${data.name}".`); - if (getFileExtension(normalizedNewPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedNewPath)}" for ${data.type} "${data.name}".`); + if (getFileExtension(normalizedPath) !== (schema.extension ?? "")) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${data.type} "${data.name}".`); + if (getFileExtension(normalizedNewPath) !== (schema.extension ?? "")) throw new Error(`Invalid extension "${getFileExtension(normalizedNewPath)}" for ${data.type} "${data.name}".`); break; case "media": if (!data.name) throw new Error(`"name" is required for media.`); @@ -78,6 +74,7 @@ export async function POST( schema = getSchemaByName(config.object, data.name, "media"); if (!schema) throw new Error(`Media schema not found for ${data.name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (!normalizedPath.startsWith(schema.input)) throw new Error(`Invalid path "${params.path}" for media.`); if (!normalizedNewPath.startsWith(schema.input)) throw new Error(`Invalid path "${data.newPath}" for media.`); @@ -92,6 +89,20 @@ export async function POST( ) throw new Error(`Invalid extension "${getFileExtension(normalizedNewPath)}" for media.`); break; } + + const commitIdentity = resolveCommitIdentity({ + configObject: config.object, + identityOverride: schemaCommitIdentity, + }); + const committer = ( + commitIdentity === "user" && + user.email + ) + ? { + name: user.name?.trim() || user.email, + email: user.email, + } + : undefined; const response = await githubRenameFile( token, diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts index 61b853b5b..22e717dff 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts @@ -11,7 +11,7 @@ import { getToken } from "@/lib/token"; import { updateFileCache } from "@/lib/github-cache"; import { createHttpError, toErrorResponse } from "@/lib/api-error"; import mergeWith from "lodash.mergewith"; -import { buildCommitTokens, resolveCommitMessage } from "@/lib/commit-message"; +import { buildCommitTokens, resolveCommitIdentity, resolveCommitMessage } from "@/lib/commit-message"; import { requireApiUserSession } from "@/lib/session-server"; /** @@ -33,14 +33,8 @@ export async function POST( if ("response" in sessionResult) return sessionResult.response; const user = sessionResult.user; - const { token, source } = await getToken(user, params.owner, params.repo, true); + const { token } = await getToken(user, params.owner, params.repo, true); if (!token) throw new Error("Token not found"); - const committer = source === "installation" - ? { - name: user.name?.trim() || user.email, - email: user.email, - } - : undefined; const normalizedPath = normalizePath(params.path); @@ -55,6 +49,7 @@ export async function POST( let contentBase64; let schema; let schemaCommitTemplates: Record | undefined; + let schemaCommitIdentity: "app" | "user" | undefined; switch (data.type) { case "content": @@ -63,6 +58,7 @@ export async function POST( schema = getSchemaByName(config?.object, data.name); if (!schema) throw new Error(`Content schema not found for ${data.name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${params.path}" for ${data.type} "${data.name}".`); @@ -74,7 +70,7 @@ export async function POST( // Folder creation contentBase64 = ""; } else { - if (getFileExtension(normalizedPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${data.type} "${data.name}".`); + if (getFileExtension(normalizedPath) !== (schema.extension ?? "")) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${data.type} "${data.name}".`); if (serializedTypes.includes(schema.format) && schema.fields) { let contentFields; @@ -166,6 +162,7 @@ export async function POST( schema = getSchemaByName(config?.object, data.name, "media"); if (!schema) throw new Error(`Media schema not found for ${data.name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (!normalizedPath.startsWith(schema.input)) throw new Error(`Invalid path "${params.path}" for media "${data.name}".`); @@ -190,6 +187,20 @@ export async function POST( default: throw new Error(`Invalid type "${data.type}".`); } + + const commitIdentity = resolveCommitIdentity({ + configObject: config?.object, + identityOverride: schemaCommitIdentity, + }); + const committer = ( + commitIdentity === "user" && + user.email + ) + ? { + name: user.name?.trim() || user.email, + email: user.email, + } + : undefined; const response = await githubSaveFile( token, @@ -422,14 +433,8 @@ export async function DELETE( if ("response" in sessionResult) return sessionResult.response; const user = sessionResult.user; - const { token, source } = await getToken(user, params.owner, params.repo, true); + const { token } = await getToken(user, params.owner, params.repo, true); if (!token) throw new Error("Token not found"); - const committer = source === "installation" - ? { - name: user.name?.trim() || user.email, - email: user.email, - } - : undefined; if (params.path === ".pages.yml") throw new Error(`Deleting the settings file isn't allowed.`); @@ -450,6 +455,7 @@ export async function DELETE( const normalizedPath = normalizePath(params.path); let schema; let schemaCommitTemplates: Record | undefined; + let schemaCommitIdentity: "app" | "user" | undefined; switch (type) { case "content": @@ -458,6 +464,7 @@ export async function DELETE( schema = getSchemaByName(config.object, name); if (!schema) throw new Error(`Content schema not found for ${name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${params.path}" for ${type} "${name}".`); @@ -465,7 +472,7 @@ export async function DELETE( throw new Error(`Subfolders are not allowed for collection "${name}".`); } - if (getFileExtension(normalizedPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${type} "${name}".`); + if (getFileExtension(normalizedPath) !== (schema.extension ?? "")) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${type} "${name}".`); break; case "media": if (!name) throw new Error(`"name" is required for media.`); @@ -473,6 +480,7 @@ export async function DELETE( schema = getSchemaByName(config.object, name, "media"); if (!schema) throw new Error(`Media schema not found for ${name}.`); schemaCommitTemplates = schema?.commit?.templates; + schemaCommitIdentity = schema?.commit?.identity; if (!normalizedPath.startsWith(schema.input)) throw new Error(`Invalid path "${params.path}" for media "${name}".`); @@ -482,6 +490,20 @@ export async function DELETE( ) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for media.`); break; } + + const commitIdentity = resolveCommitIdentity({ + configObject: config.object, + identityOverride: schemaCommitIdentity, + }); + const committer = ( + commitIdentity === "user" && + user.email + ) + ? { + name: user.name?.trim() || user.email, + email: user.email, + } + : undefined; const octokit = createOctokitInstance(token); const response = await octokit.rest.repos.deleteFile({ diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index 2c6051cb1..9b7aba282 100644 --- a/app/api/webhook/github/route.ts +++ b/app/api/webhook/github/route.ts @@ -1,7 +1,7 @@ import { after } from "next/server"; import crypto from "crypto"; import { db } from "@/db"; -import { cacheFileTable, collaboratorTable, configTable, githubInstallationTokenTable } from "@/db/schema"; +import { actionRunTable, cacheFileTable, collaboratorTable, configTable, githubInstallationTokenTable } from "@/db/schema"; import { and, eq, inArray, sql } from "drizzle-orm"; import { normalizePath } from "@/lib/utils/file"; import { createOctokitInstance } from "@/lib/utils/octokit"; @@ -14,7 +14,7 @@ import { import { getInstallationToken } from "@/lib/token"; import { configVersion, normalizeConfig, parseConfig } from "@/lib/config"; import { saveConfig, updateConfig } from "@/lib/utils/config"; -import { deleteCacheFileMeta, upsertCacheFileMeta } from "@/lib/cache-file-meta"; +import { deleteCacheFileMeta, deleteCacheFileMetaByPaths, upsertCacheFileMeta } from "@/lib/cache-file-meta"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -82,6 +82,7 @@ const clearScopedFileCache = async ( ) => { const uniqueChangedPaths = Array.from(new Set(changedPaths.filter(Boolean))); const affectedParentPaths = getAffectedParentPaths(uniqueChangedPaths); + await deleteCacheFileMetaByPaths(owner, repo, branch, affectedParentPaths); const whereBase = and( eq(cacheFileTable.owner, owner.toLowerCase()), eq(cacheFileTable.repo, repo.toLowerCase()), @@ -125,6 +126,7 @@ const processWebhookEvent = async (event: string | null, data: any) => { eq(githubInstallationTokenTable.installationId, data.installation.id), ), clearFileCache(accountLogin), + deleteCacheFileMeta(accountLogin), ]); } break; @@ -142,7 +144,10 @@ const processWebhookEvent = async (event: string | null, data: any) => { (data.repositories_removed || []).map((repo: any) => { const [owner, repoName] = (repo.full_name || "").split("/"); if (owner && repoName) { - return clearFileCache(owner, repoName); + return Promise.all([ + clearFileCache(owner, repoName), + deleteCacheFileMeta(owner, repoName), + ]); } return Promise.resolve(); }), @@ -166,6 +171,7 @@ const processWebhookEvent = async (event: string | null, data: any) => { eq(collaboratorTable.repoId, repoId), ), clearFileCache(owner, repoName), + deleteCacheFileMeta(owner, repoName), ]); } else if (data.action === "transferred") { const oldOwner = data.changes?.owner?.from?.login || owner; @@ -175,6 +181,7 @@ const processWebhookEvent = async (event: string | null, data: any) => { eq(collaboratorTable.repoId, repoId), ), clearFileCache(oldOwner, repoName), + deleteCacheFileMeta(oldOwner, repoName), ]); } else if (data.action === "renamed") { const oldName = data.changes?.repository?.name?.from; @@ -301,8 +308,9 @@ const processWebhookEvent = async (event: string | null, data: any) => { }); await clearFileCache(pushOwner, pushRepo, pushBranch); + await deleteCacheFileMeta(pushOwner, pushRepo, pushBranch); await upsertCacheFileMeta(pushOwner, pushRepo, pushBranch, { - sha: commit.sha, + commitSha: commit.sha, status: "ok", error: null, }); @@ -323,8 +331,9 @@ const processWebhookEvent = async (event: string | null, data: any) => { }); await clearScopedFileCache(pushOwner, pushRepo, pushBranch, uniqueChangedPaths); + await deleteCacheFileMeta(pushOwner, pushRepo, pushBranch); await upsertCacheFileMeta(pushOwner, pushRepo, pushBranch, { - sha: commit.sha, + commitSha: commit.sha, status: "ok", error: null, }); @@ -354,7 +363,7 @@ const processWebhookEvent = async (event: string | null, data: any) => { ); await upsertCacheFileMeta(pushOwner, pushRepo, pushBranch, { - sha: commit.sha, + commitSha: commit.sha, status: "ok", error: null, }); @@ -412,6 +421,22 @@ const processWebhookEvent = async (event: string | null, data: any) => { } break; } + + case "workflow_run": { + const workflowRunId = data.workflow_run?.id; + if (!workflowRunId) break; + + await db.update(actionRunTable).set({ + status: data.workflow_run?.status ?? "completed", + conclusion: data.workflow_run?.conclusion ?? null, + htmlUrl: data.workflow_run?.html_url ?? null, + updatedAt: new Date(), + completedAt: data.workflow_run?.status === "completed" + ? new Date(data.workflow_run?.updated_at ?? new Date().toISOString()) + : null, + }).where(eq(actionRunTable.workflowRunId, workflowRunId)); + break; + } } }; diff --git a/app/error.tsx b/app/error.tsx index 9b68a2113..15a01fd71 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -36,13 +36,13 @@ export default function Error({ Go home diff --git a/app/not-found.tsx b/app/not-found.tsx index ccb928ee0..4cac55f1c 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -10,7 +10,7 @@ export default function NotFound() { The page or resource you requested could not be found. - + Go home diff --git a/components/actions/actions-page.tsx b/components/actions/actions-page.tsx new file mode 100644 index 000000000..4774ed603 --- /dev/null +++ b/components/actions/actions-page.tsx @@ -0,0 +1,811 @@ +"use client"; + +import Link from "next/link"; +import { forwardRef, useCallback, useEffect, useMemo, useState } from "react"; +import { formatDistanceToNowStrict } from "date-fns"; +import { + ArrowUpRight, + BookText, + CircleCheck, + CircleX, + EllipsisVertical, + Funnel, + Loader, +} from "lucide-react"; +import { toast } from "sonner"; +import { useRepoHeader } from "@/components/repo/repo-header-context"; +import { useActionToasts } from "@/contexts/action-toast-context"; +import { getInitialsFromName } from "@/lib/utils/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { Input } from "@/components/ui/input"; +import { requireApiSuccess } from "@/lib/api-client"; +import { + formatActionRunState, + isActionRunActive, + type ActionRunSummary, +} from "@/lib/repo-actions"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +const PAGE_SIZE = 25; +const PAGE_BUTTON_COUNT = 5; + +const getRunIcon = (run: ActionRunSummary) => { + if (isActionRunActive(run)) return ; + if (run.conclusion === "success") + return ( + + ); + return ; +}; + +const getStatusLabel = (run: ActionRunSummary) => formatActionRunState(run); + +const getStatusFilterValue = (run: ActionRunSummary) => { + if (isActionRunActive(run)) return "pending"; + if (run.conclusion === "success") return "succeeded"; + return "failed"; +}; + +const formatContext = ( + run: ActionRunSummary, + contextLabels: Record, +) => { + const contextLabel = + run.contextType && run.contextName + ? (contextLabels[`${run.contextType}:${run.contextName}`] ?? + run.contextName) + : null; + + switch (run.contextType) { + case "collection": + return `Collection (${contextLabel ?? "-"})`; + case "entry": + return `Entry (${contextLabel ?? "-"})`; + case "media": + return `Media (${contextLabel ?? "-"})`; + case "file": + return `File (${contextLabel ?? "-"})`; + default: + return "Sidebar"; + } +}; + +const formatDetails = (run: ActionRunSummary) => [ + { label: "Surface", value: run.contextType ?? "-" }, + { label: "Name", value: run.contextName ?? "-" }, + { label: "Path", value: run.contextPath ?? "-" }, +]; + +const getShaUrl = (owner: string, repo: string, sha: string | null) => { + if (!sha) return null; + return `https://github.com/${owner}/${repo}/commit/${sha}`; +}; + +const UnderlinedTrigger = forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<"button"> +>(function UnderlinedTrigger({ children, className, ...props }, ref) { + return ( + + ); +}); + +function DetailValue({ value }: { value: string }) { + if (value === "-") { + return -; + } + + return {value}; +} + +function ActionsPagination({ + pageCount, + pageIndex, + paginationItems, + onPrevious, + onNext, + onPageSelect, +}: { + pageCount: number; + pageIndex: number; + paginationItems: Array; + onPrevious: () => void; + onNext: () => void; + onPageSelect: (page: number) => void; +}) { + if (pageCount <= 1) return null; + + return ( +
+ + + + { + event.preventDefault(); + onPrevious(); + }} + className={ + pageIndex === 0 ? "pointer-events-none opacity-50" : undefined + } + /> + + {paginationItems.map((item, index) => ( + + {item === "ellipsis" ? ( + + ) : ( + { + event.preventDefault(); + onPageSelect(item); + }} + > + {item + 1} + + )} + + ))} + + { + event.preventDefault(); + onNext(); + }} + className={ + pageIndex >= pageCount - 1 + ? "pointer-events-none opacity-50" + : undefined + } + /> + + + +
+ ); +} + +function ActionsTableSkeleton() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: PAGE_SIZE }).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ + + {Array.from({ length: PAGE_BUTTON_COUNT + 2 }).map((_, index) => ( + + + + ))} + + +
+
+ ); +} + +type ActionsPageProps = { + owner: string; + repo: string; + branch: string; + actionLabels?: Record; + contextLabels?: Record; +}; + +export function ActionsPage({ + owner, + repo, + branch, + actionLabels = {}, + contextLabels = {}, +}: ActionsPageProps) { + const { trackActionRun } = useActionToasts(); + const [runs, setRuns] = useState(null); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [actionFilter, setActionFilter] = useState("all"); + const [triggeredByFilter, setTriggeredByFilter] = useState("all"); + const [pageIndex, setPageIndex] = useState(0); + + const loadRuns = useCallback(async () => { + const response = await fetch( + `/api/${owner}/${repo}/${encodeURIComponent(branch)}/actions?all=1`, + ); + const payload = await requireApiSuccess<{ data: ActionRunSummary[] }>( + response, + "Failed to fetch action runs", + ); + setRuns(payload.data); + }, [branch, owner, repo]); + + const handleRunAction = useCallback(async ( + run: ActionRunSummary, + intent: "cancel" | "rerun", + ) => { + try { + const response = await fetch( + `/api/${owner}/${repo}/${encodeURIComponent(branch)}/actions/${run.id}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ intent }), + }, + ); + const payload = await requireApiSuccess<{ data?: ActionRunSummary | { id: number } }>( + response, + `Failed to ${intent === "cancel" ? "cancel" : "run"} action`, + ); + + if (intent === "rerun" && payload.data && "id" in payload.data) { + const actionLabel = actionLabels[run.actionName] ?? run.actionName; + const toastId = toast.loading(`Starting "${actionLabel}"…`); + trackActionRun({ + runId: payload.data.id, + owner, + repo, + refName: branch, + actionLabel, + toastId, + }); + } else if (intent === "cancel") { + toast.success("Run cancelled."); + } + + await loadRuns(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Action failed."); + } + }, [actionLabels, branch, loadRuns, owner, repo, trackActionRun]); + + useEffect(() => { + void loadRuns(); + }, [loadRuns]); + + useEffect(() => { + const interval = window.setInterval(() => { + if (document.hidden) return; + void loadRuns(); + }, 4000); + + return () => window.clearInterval(interval); + }, [loadRuns]); + + const actionOptions = useMemo( + () => Array.from(new Set((runs ?? []).map((run) => run.actionName))).sort(), + [runs], + ); + const triggeredByOptions = useMemo( + () => + Array.from( + new Set( + (runs ?? []) + .map((run) => run.triggeredByName) + .filter((value): value is string => Boolean(value)), + ), + ).sort(), + [runs], + ); + + const filteredRuns = useMemo(() => { + return (runs ?? []).filter((run) => { + const status = getStatusFilterValue(run); + const actionLabel = actionLabels[run.actionName] ?? run.actionName; + const haystack = [ + actionLabel, + run.actionName, + run.triggeredByName ?? "", + run.contextType ?? "", + run.contextName ?? "", + run.contextPath ?? "", + run.workflowRef ?? "", + run.sha ?? "", + ] + .join(" ") + .toLowerCase(); + + if (statusFilter !== "all" && status !== statusFilter) return false; + if (actionFilter !== "all" && run.actionName !== actionFilter) + return false; + if ( + triggeredByFilter !== "all" && + run.triggeredByName !== triggeredByFilter + ) + return false; + if (search && !haystack.includes(search.toLowerCase())) return false; + return true; + }); + }, [ + actionFilter, + actionLabels, + runs, + search, + statusFilter, + triggeredByFilter, + ]); + + useEffect(() => { + setPageIndex(0); + }, [search, statusFilter, actionFilter, triggeredByFilter]); + + const pageCount = Math.max(1, Math.ceil(filteredRuns.length / PAGE_SIZE)); + + useEffect(() => { + if (pageIndex > pageCount - 1) { + setPageIndex(Math.max(0, pageCount - 1)); + } + }, [pageCount, pageIndex]); + + const paginationItems = useMemo(() => { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i); + } + + const pages = new Set([0, pageCount - 1, pageIndex]); + if (pageIndex - 1 >= 0) pages.add(pageIndex - 1); + if (pageIndex + 1 < pageCount) pages.add(pageIndex + 1); + + const ordered = Array.from(pages).sort((a, b) => a - b); + const items: Array = []; + + for (let i = 0; i < ordered.length; i += 1) { + if (i > 0 && ordered[i] - ordered[i - 1] > 1) { + items.push("ellipsis"); + } + items.push(ordered[i]); + } + + return items; + }, [pageCount, pageIndex]); + + const pagedRuns = useMemo(() => { + const start = pageIndex * PAGE_SIZE; + return filteredRuns.slice(start, start + PAGE_SIZE); + }, [filteredRuns, pageIndex]); + + const hasActiveFilters = + search !== "" || + statusFilter !== "all" || + actionFilter !== "all" || + triggeredByFilter !== "all"; + + const headerNode = useMemo( + () => ( +
+
+

Actions

+ + + + + View docs + +
+
+ + setSearch(event.target.value)} + placeholder="Search" + className="w-44" + /> + + + + + + + + Filters + + +
+ + + +
+
+
+
+ + + + + Reset filters + +
+
+ ), + [ + actionFilter, + actionLabels, + actionOptions, + hasActiveFilters, + search, + statusFilter, + triggeredByFilter, + triggeredByOptions, + ], + ); + + useRepoHeader({ header: headerNode }); + + if (runs == null) { + return ; + } + + return ( +
+ + + + + Name + Context + Triggered + Triggered by + Ref + + + + + {filteredRuns.length === 0 ? ( + + + {runs.length === 0 ? "No actions yet." : "No matching actions."} + + + ) : ( + pagedRuns.map((run) => ( + + + + {getRunIcon(run)} + {getStatusLabel(run)} + + + + {run.actionName} + + + + + + {formatContext(run, contextLabels)} + + + +
    + {formatDetails(run).map((item) => ( +
  • + {item.label}: + +
  • + ))} +
+
+
+
+ + {run.createdAt + ? formatDistanceToNowStrict(new Date(run.createdAt), { + addSuffix: true, + }) + : "-"} + + + {run.triggeredByName ? ( + + + + {run.triggeredByName} + + + +
+ + + + {getInitialsFromName( + run.triggeredByName ?? undefined, + )} + + +
+
+ {run.triggeredByName} +
+ {run.triggeredByGithubUsername ? ( + + @{run.triggeredByGithubUsername} + + ) : null} + {run.triggeredByEmail ? ( +
+ {run.triggeredByEmail} +
+ ) : ( +
-
+ )} +
+
+
+
+ ) : ( + "-" + )} +
+ + {run.sha ? ( + + {run.workflowRef + ? `${run.workflowRef}@${run.sha.slice(0, 7)}` + : run.sha.slice(0, 7)} + + ) : ( + + {run.workflowRef ?? "-"} + + )} + + + + + + + + + + View on GitHub + + + + + void handleRunAction(run, "rerun")} + > + Run again + + void handleRunAction(run, "cancel")} + > + Cancel run + + + + +
+ )) + )} +
+
+ { + if (pageIndex > 0) setPageIndex((current) => current - 1); + }} + onNext={() => { + if (pageIndex < pageCount - 1) { + setPageIndex((current) => current + 1); + } + }} + onPageSelect={setPageIndex} + /> +
+ ); +} diff --git a/components/cache/cache-page.tsx b/components/cache/cache-page.tsx index 42575d834..93c2a558d 100644 --- a/components/cache/cache-page.tsx +++ b/components/cache/cache-page.tsx @@ -37,12 +37,20 @@ import { requireApiSuccess } from "@/lib/api-client"; type CacheStatusPayload = { fileMeta: { - sha: string | null; + commitSha: string | null; status: string; error: string | null; updatedAt: string; lastCheckedAt: string; } | null; + folderMeta: Array<{ + path: string; + context: string; + status: string; + commitSha: string | null; + targetCommitSha: string | null; + updatedAt: string; + }>; fileCount: number; permissionCount: number; config: { @@ -252,6 +260,7 @@ export function CachePage({ description="This will clear file, config, and permission cache for this repository/branch." confirmLabel="Clear all" variant="default" + size="default" disabled={loading || actionLoading != null} onConfirm={async () => runAction("clear-all-cache", "All cache cleared") @@ -265,7 +274,7 @@ export function CachePage({ useRepoHeader({ header: headerNode }); const remoteSha = data?.branchHeadSha ?? null; - const localSha = data?.fileMeta?.sha ?? null; + const localSha = data?.fileMeta?.commitSha ?? null; const isOutOfSync = !!remoteSha && !!localSha && remoteSha !== localSha; const canReconcile = !!data && isOutOfSync; const shortSha = (sha: string | null | undefined) => @@ -288,6 +297,10 @@ export function CachePage({ Files cached +
+ Folder caches + +
Cache SHA @@ -337,7 +350,8 @@ export function CachePage({ Config - Cache of the configuration file (.pages.yml). + Cache of the configuration file ( + .pages.yml). @@ -422,6 +436,10 @@ export function CachePage({ Files cached {data.fileCount}
+
+ Folder caches + {data.folderMeta.length} +
Cache SHA @@ -504,7 +522,8 @@ export function CachePage({ Config - Cache of the configuration file (.pages.yml). + Cache of the configuration file ( + .pages.yml). diff --git a/components/collaborators.tsx b/components/collaborators.tsx index d60f414ae..46d80e3c9 100644 --- a/components/collaborators.tsx +++ b/components/collaborators.tsx @@ -54,7 +54,7 @@ import { } from "@/components/ui/tooltip"; import { requireApiSuccess } from "@/lib/api-client"; import { toast } from "sonner"; -import { BookText, EllipsisVertical, Loader, Mail, Trash2 } from "lucide-react"; +import { BookText, EllipsisVertical, Loader } from "lucide-react"; type Collaborator = { id: number; @@ -332,6 +332,7 @@ export function Collaborators({ onValueChange={setEmails} disabled={isLoading} triggerVariant="default" + triggerSize="default" /> ) : null}
@@ -366,7 +367,7 @@ export function Collaborators({ className="ml-auto" disabled > - + ))} @@ -435,9 +436,9 @@ export function Collaborators({ > {removing.includes(collaborator.id) || resending.includes(collaborator.id) ? ( - + ) : ( - + )} Collaborator actions @@ -519,7 +520,7 @@ export function Collaborators({ disabled={isLoading} triggerLabel="Invite a collaborator" triggerVariant="default" - triggerSize="sm" + triggerSize="default" /> diff --git a/components/collection/collection.tsx b/components/collection/collection.tsx index 70939b64c..50471d035 100644 --- a/components/collection/collection.tsx +++ b/components/collection/collection.tsx @@ -1,24 +1,39 @@ "use client"; -import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + Fragment, + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useConfig } from "@/contexts/config-context"; +import { RepoActionButtons } from "@/components/repo/repo-action-buttons"; import { getParentPath, getFileName, getRelativePath, joinPathSegments, normalizePath, - sortFiles + sortFiles, } from "@/lib/utils/file"; import { viewComponents } from "@/fields/registry"; -import { getSchemaByName, getPrimaryField, getFieldByPath, safeAccess } from "@/lib/schema"; +import { getSchemaActions } from "@/lib/repo-actions"; +import { + getSchemaByName, + getPrimaryField, + getFieldByPath, + safeAccess, +} from "@/lib/schema"; import { requireApiSuccess } from "@/lib/api-client"; import { EmptyCreate } from "@/components/empty-create"; import { FileOptions } from "@/components/file/file-options"; import { CollectionTable } from "./collection-table"; -import { FolderCreate} from "@/components/folder-create"; +import { FolderCreate } from "@/components/folder-create"; import { useRepoHeader } from "@/components/repo/repo-header-context"; import { Button, buttonVariants } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; @@ -60,16 +75,21 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { EllipsisVertical, FolderPlus, Plus, Search } from "lucide-react"; import { - EllipsisVertical, - FolderPlus, - Plus, - Search -} from "lucide-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +type GroupTrailItem = { + name: string; + label?: string | null; +}; const CollectionHeaderActions = memo(function CollectionHeaderActions({ addEntryHref, + actionNode, collectionPath, name, showFolderCreate, @@ -77,6 +97,7 @@ const CollectionHeaderActions = memo(function CollectionHeaderActions({ onSearchChange, }: { addEntryHref: string; + actionNode?: ReactNode; collectionPath: string; name: string; showFolderCreate: boolean; @@ -92,10 +113,11 @@ const CollectionHeaderActions = memo(function CollectionHeaderActions({ return (
+ {actionNode}
- + setSearchInput(e.target.value)} placeholder="Search entries..." @@ -105,8 +127,17 @@ const CollectionHeaderActions = memo(function CollectionHeaderActions({
- - @@ -115,23 +146,26 @@ const CollectionHeaderActions = memo(function CollectionHeaderActions({ Create folder )} - + Add an entry - - + +
); }); -export function Collection({ - name, - path, -}: { - name: string; - path?: string; -}) { +export function Collection({ name, path }: { name: string; path?: string }) { const [tableSearch, setTableSearch] = useState(""); const [data, setData] = useState[]>([]); const [error, setError] = useState(null); @@ -144,9 +178,13 @@ export function Collection({ const { config } = useConfig(); if (!config) throw new Error(`Configuration not found.`); - const schema = useMemo(() => getSchemaByName(config?.object, name), [config, name]); + const schema = useMemo( + () => getSchemaByName(config?.object, name), + [config, name], + ); if (!schema) throw new Error(`Schema not found for "${name}".`); - if (schema.type !== "collection") throw new Error(`"${name}" is not a collection.`); + if (schema.type !== "collection") + throw new Error(`"${name}" is not a collection.`); const viewFields = useMemo(() => { let pathAndFieldArray: any[] = []; @@ -155,11 +193,15 @@ export function Collection({ // If we have a list of fields defined for the view schema.view.fields.forEach((path: string) => { const field = getFieldByPath(schema.fields, path); - if (field && !['object', 'block'].includes(field.type)) pathAndFieldArray.push({ path: path, field: field }); + if (field && !["object", "block"].includes(field.type)) + pathAndFieldArray.push({ path: path, field: field }); }); } else { pathAndFieldArray = schema.fields - .filter((field: any) => !['object', 'block'].includes(field.type) && !field.hidden) + .filter( + (field: any) => + !["object", "block"].includes(field.type) && !field.hidden, + ) .map((field: any) => ({ path: field.name, field: field })); } } else { @@ -168,35 +210,40 @@ export function Collection({ field: { label: "Name", name: "name", - type: "string" - } + type: "string", + }, }); } // If the filename starts with {year}-{month}-{day} and date is listed in the // view fields and is not an actual field, or if there are no fields, we add a date field if ( - !pathAndFieldArray.find((item: any) => item.path === "date") - && schema.filename.startsWith("{year}-{month}-{day}") - && ( - (schema.view?.fields && schema.view?.fields.includes("date")) - || !schema.view?.fields - ) + !pathAndFieldArray.find((item: any) => item.path === "date") && + schema.filename.startsWith("{year}-{month}-{day}") && + ((schema.view?.fields && schema.view?.fields.includes("date")) || + !schema.view?.fields) ) { pathAndFieldArray.push({ path: "date", field: { label: "Date", name: "date", - type: "date" - } + type: "date", + }, }); } return pathAndFieldArray; }, [schema]); - const primaryField = useMemo(() => getPrimaryField(schema) ?? "name", [schema]); + const primaryField = useMemo( + () => getPrimaryField(schema) ?? "name", + [schema], + ); + const collectionActions = useMemo( + () => getSchemaActions(schema, "collection"), + [schema], + ); const requestedFieldPaths = useMemo(() => { const paths = new Set(["name", "path", primaryField]); viewFields.forEach((item: any) => paths.add(item.path)); @@ -207,46 +254,54 @@ export function Collection({ setTableSearch(value); }, []); - const buildCollectionApiUrl = useCallback((fetchPath: string): string => { - const params = new URLSearchParams({ - path: fetchPath, - fields: requestedFieldPaths.join(","), - }); - return `/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collections/${encodeURIComponent(name)}?${params.toString()}`; - }, [config.branch, config.owner, config.repo, name, requestedFieldPaths]); + const buildCollectionApiUrl = useCallback( + (fetchPath: string): string => { + const params = new URLSearchParams({ + path: fetchPath, + fields: requestedFieldPaths.join(","), + }); + return `/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collections/${encodeURIComponent(name)}?${params.toString()}`; + }, + [config.branch, config.owner, config.repo, name, requestedFieldPaths], + ); - const fetchCollectionByUrl = useCallback(async (apiUrl: string): Promise[]> => { - const response = await fetch(apiUrl); - const result = await requireApiSuccess(response, "Fetch failed"); + const fetchCollectionByUrl = useCallback( + async (apiUrl: string): Promise[]> => { + const response = await fetch(apiUrl); + const result = await requireApiSuccess(response, "Fetch failed"); - if (result.data.errors?.length) { - result.data.errors.forEach((e: any) => toast.error(e)); - } + if (result.data.errors?.length) { + result.data.errors.forEach((e: any) => toast.error(e)); + } - const unsortedData = result.data.contents || []; - if (unsortedData.length === 0) return []; - return unsortedData.sort((a: any, b: any) => { - if (a.type === "dir" && b.type === "file") return schema.view?.foldersFirst ? -1 : 1; - if (a.type === "file" && b.type === "dir") return schema.view?.foldersFirst ? 1 : -1; - return a.name.localeCompare(b.name); - }); - }, [schema.view?.foldersFirst]); - - const collectionPath = schema.view?.layout === "tree" - ? schema.path - : path || schema.path; - const rootCollectionKey = useMemo(() => buildCollectionApiUrl(collectionPath), [buildCollectionApiUrl, collectionPath]); - - const { data: swrCollectionData, error: swrCollectionError } = useSWR[]>( - rootCollectionKey, - fetchCollectionByUrl, - { - revalidateOnFocus: true, - revalidateOnReconnect: true, - dedupingInterval: 2000, + const unsortedData = result.data.contents || []; + if (unsortedData.length === 0) return []; + return unsortedData.sort((a: any, b: any) => { + if (a.type === "dir" && b.type === "file") + return schema.view?.foldersFirst ? -1 : 1; + if (a.type === "file" && b.type === "dir") + return schema.view?.foldersFirst ? 1 : -1; + return a.name.localeCompare(b.name); + }); }, + [schema.view?.foldersFirst], + ); + + const collectionPath = + schema.view?.layout === "tree" ? schema.path : path || schema.path; + const rootCollectionKey = useMemo( + () => buildCollectionApiUrl(collectionPath), + [buildCollectionApiUrl, collectionPath], ); + const { data: swrCollectionData, error: swrCollectionError } = useSWR< + Record[] + >(rootCollectionKey, fetchCollectionByUrl, { + revalidateOnFocus: true, + revalidateOnReconnect: true, + dedupingInterval: 2000, + }); + useEffect(() => { setData([]); setError(null); @@ -260,29 +315,46 @@ export function Collection({ useEffect(() => { if (!swrCollectionError) return; - setError(swrCollectionError instanceof Error ? swrCollectionError.message : "Fetch failed"); + setError( + swrCollectionError instanceof Error + ? swrCollectionError.message + : "Fetch failed", + ); }, [swrCollectionError]); - const fetchCollectionData = useCallback(async (fetchPath: string): Promise[] | undefined> => { - const apiUrl = buildCollectionApiUrl(fetchPath); - const cachedValue = cache.get(apiUrl) as { data?: Record[] } | undefined; - if (cachedValue?.data) return cachedValue.data; - - try { - const rows = await fetchCollectionByUrl(apiUrl); - await mutate(apiUrl, rows, { revalidate: false }); - return rows; - - } catch (err: any) { - console.error(`Fetch failed for path ${fetchPath}:`, err); - if (fetchPath === (path || schema.path)) { - setError(err.message); - } else { - toast.error(`Could not load items inside ${getFileName(fetchPath)}: ${err.message}`); + const fetchCollectionData = useCallback( + async (fetchPath: string): Promise[] | undefined> => { + const apiUrl = buildCollectionApiUrl(fetchPath); + const cachedValue = cache.get(apiUrl) as + | { data?: Record[] } + | undefined; + if (cachedValue?.data) return cachedValue.data; + + try { + const rows = await fetchCollectionByUrl(apiUrl); + await mutate(apiUrl, rows, { revalidate: false }); + return rows; + } catch (err: any) { + console.error(`Fetch failed for path ${fetchPath}:`, err); + if (fetchPath === (path || schema.path)) { + setError(err.message); + } else { + toast.error( + `Could not load items inside ${getFileName(fetchPath)}: ${err.message}`, + ); + } + return undefined; } - return undefined; - } - }, [buildCollectionApiUrl, cache, fetchCollectionByUrl, mutate, path, schema.path]); + }, + [ + buildCollectionApiUrl, + cache, + fetchCollectionByUrl, + mutate, + path, + schema.path, + ], + ); const handleDelete = useCallback((path: string) => { setData((prevData) => prevData?.filter((item: any) => item.path !== path)); @@ -291,14 +363,14 @@ export function Collection({ const handleRename = useCallback((path: string, newPath: string) => { setData((prevData: any) => { if (!prevData) return prevData; - + const updateNestedData = (items: any[]): any[] => { return items.map((item: any) => { // If this is the item being renamed if (item.path === path) { return { ...item, path: newPath, name: getFileName(newPath) }; } - + // If this item has subRows, recursively update them if (item.subRows && Array.isArray(item.subRows)) { const updatedSubRows = updateNestedData(item.subRows); @@ -307,14 +379,17 @@ export function Collection({ return { ...item, subRows: updatedSubRows }; } } - + // Return the original item if no changes return item; }); }; - + // Check if the item is moving to a different folder - if (getParentPath(normalizePath(path)) !== getParentPath(normalizePath(newPath))) { + if ( + getParentPath(normalizePath(path)) !== + getParentPath(normalizePath(newPath)) + ) { // For items moved to a different folder, we need to: // 1. Remove the item from its original location (recursively) const removeItem = (items: any[]): any[] => { @@ -330,10 +405,10 @@ export function Collection({ return item; }); }; - + return sortFiles(removeItem(prevData)); } - + // For items renamed within the same folder, update the item return sortFiles(updateNestedData(prevData)); }); @@ -347,271 +422,362 @@ export function Collection({ path: parentPath, size: 0, url: null, - } - + }; + setData((prevData) => { if (!prevData) return [parent]; return sortFiles([...prevData, parent]); }); }, []); - const handleConfirmRenameNode = useCallback((path: string, newPath: string) => { - try { - const normalizedPath = normalizePath(path); - const normalizedNewPath = normalizePath(newPath); - - const renamePromise = new Promise(async (resolve, reject) => { - try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(normalizedPath)}/rename`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "content", - name, - newPath: normalizedNewPath, - }), - }); - const data = await requireApiSuccess(response, "Failed to rename file"); - - resolve(data); - } catch (error) { - reject(error); - } - }); + const handleConfirmRenameNode = useCallback( + (path: string, newPath: string) => { + try { + const normalizedPath = normalizePath(path); + const normalizedNewPath = normalizePath(newPath); + + const renamePromise = new Promise(async (resolve, reject) => { + try { + const response = await fetch( + `/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(normalizedPath)}/rename`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "content", + name, + newPath: normalizedNewPath, + }), + }, + ); + const data = await requireApiSuccess( + response, + "Failed to rename file", + ); - toast.promise(renamePromise, { - loading: `Renaming "${path}" to "${newPath}"`, - success: (data: any) => { - router.push(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/new?parent=${encodeURIComponent(getParentPath(normalizedNewPath))}`); - return data.message; - }, - error: (error: any) => error.message, - }); - } catch (error) { - console.error(error); - } - }, [config.owner, config.repo, config.branch, name, router]); + resolve(data); + } catch (error) { + reject(error); + } + }); + + toast.promise(renamePromise, { + loading: `Renaming "${path}" to "${newPath}"`, + success: (data: any) => { + router.push( + `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/new?parent=${encodeURIComponent(getParentPath(normalizedNewPath))}`, + ); + return data.message; + }, + error: (error: any) => error.message, + }); + } catch (error) { + console.error(error); + } + }, + [config.owner, config.repo, config.branch, name, router], + ); const columns = useMemo(() => { let tableColumns: any; - tableColumns = viewFields.map((pathAndField: any) => { - const path = pathAndField.path; - const field = pathAndField.field; - if (!field) return null; - - return { - id: path, - accessorKey: path, - accessorFn: (originalRow: any) => safeAccess(originalRow.fields, path), - header: field?.label ?? field.name, - meta: { className: path === primaryField ? "truncate w-full min-w-[12rem] max-w-[1px]" : "" }, - cell: ({ cell, row }: { cell: any, row: any }) => { - const cellValue = cell.getValue(); - const FieldComponent = viewComponents?.[field.type]; - const CellView = FieldComponent - ? - : Array.isArray(cellValue) - ? cellValue.join(', ') - : cellValue; - if (path === primaryField) { - return ( - - {CellView} - - ); - } - return ( -
- {CellView} -
- ); - }, - sortUndefined: schema.view?.foldersFirst ? "first" : "last" - }; - }).filter(Boolean) || []; + tableColumns = + viewFields + .map((pathAndField: any) => { + const path = pathAndField.path; + const field = pathAndField.field; + if (!field) return null; + + return { + id: path, + accessorKey: path, + accessorFn: (originalRow: any) => + safeAccess(originalRow.fields, path), + header: field?.label ?? field.name, + meta: { + className: + path === primaryField + ? "truncate w-full min-w-[12rem] max-w-[1px]" + : "", + }, + cell: ({ cell, row }: { cell: any; row: any }) => { + const cellValue = cell.getValue(); + const FieldComponent = viewComponents?.[field.type]; + const CellView = FieldComponent ? ( + + ) : Array.isArray(cellValue) ? ( + cellValue.join(", ") + ) : ( + cellValue + ); + if (path === primaryField) { + return ( + + {CellView} + + ); + } + return ( +
{CellView}
+ ); + }, + sortUndefined: schema.view?.foldersFirst ? "first" : "last", + }; + }) + .filter(Boolean) || []; tableColumns.push({ accessorKey: "actions", header: "Actions", cell: ({ row }: { row: any }) => (
- {row.original.type === 'file' && + {row.original.type === "file" && ( Edit - - - } - {schema.view?.layout === 'tree' && ( - row.original.type === 'file' && + )} + {schema.view?.layout === "tree" && + (row.original.type === "file" && !row.original.isNode && - !(row.depth === 0 && row.original.name === schema.view?.node?.filename) - ? - - - - - - - Add children entry - - - - Rename this file first? - - Before adding children to this file, you must rename it from "{row.original.path}" to - "{row.original.path.replace(`.${schema.extension}`, `/${schema.view?.node?.filename}`)}". - - - - Cancel - handleConfirmRenameNode(row.original.path, row.original.path.replace(`.${schema.extension}`, `/${schema.view?.node?.filename}`))}>Rename - - - - : + !( + row.depth === 0 && + row.original.name === schema.view?.node?.filename + ) ? ( + + - + + + + Add children entry + + + + Rename this file first? + + Before adding children to this file, you must rename it + from "{row.original.path}" to " + {row.original.path.replace( + `.${schema.extension}`, + `/${schema.view?.node?.filename}`, + )} + ". + + + + Cancel + + handleConfirmRenameNode( + row.original.path, + row.original.path.replace( + `.${schema.extension}`, + `/${schema.view?.node?.filename}`, + ), + ) + } + > + Rename + + + + + ) : ( + + + - - - - - Add children entry - - - )} + } + > + + + + Add children entry + + ))}
), - enableSorting: false + enableSorting: false, }); return tableColumns; - }, [config.owner, config.repo, config.branch, name, viewFields, primaryField, handleDelete, handleRename, schema.view?.foldersFirst, schema.view?.layout, schema.view?.node?.filename, schema.extension, handleConfirmRenameNode]); + }, [ + config.owner, + config.repo, + config.branch, + name, + viewFields, + primaryField, + handleDelete, + handleRename, + schema.view?.foldersFirst, + schema.view?.layout, + schema.view?.node?.filename, + schema.extension, + handleConfirmRenameNode, + ]); const initialState = useMemo(() => { - const sortId = viewFields == null - ? "name" - : ( - schema.view?.default?.sort - || (viewFields.find((item: any) => item.field.name === "date") && "date") - || primaryField - ); + const sortId = + viewFields == null + ? "name" + : schema.view?.default?.sort || + (viewFields.find((item: any) => item.field.name === "date") && + "date") || + primaryField; return { - sorting: [{ - id: sortId, - desc: sortId === "date" - ? true - : schema.view?.default?.order === "desc" - ? true - : false, - }], + sorting: [ + { + id: sortId, + desc: + sortId === "date" + ? true + : schema.view?.default?.order === "desc" + ? true + : false, + }, + ], pagination: { pageSize: 25, }, }; }, [schema, primaryField, viewFields]); - const handleNavigate = useCallback((newPath: string) => { - const params = new URLSearchParams(Array.from(searchParams.entries())); - params.set("path", newPath || schema.path); - router.push(`${pathname}?${params.toString()}`); - }, [pathname, router, schema.path, searchParams]); - - const handleExpand = useCallback(async (row: any) => { - if (!row) return; - const subRows = await fetchCollectionData(row.isNode ? row.parentPath : row.path); - if (subRows !== undefined) { - setData((currentData: any[]) => { - const updateNestedData = (items: any[]): any[] => { - return items.map((item: any) => { - if (item.path === row.path) return { ...item, subRows }; - if (item.subRows && Array.isArray(item.subRows)) { - const updatedSubRows = updateNestedData(item.subRows); - if (updatedSubRows !== item.subRows) { - return { ...item, subRows: updatedSubRows }; + const handleNavigate = useCallback( + (newPath: string) => { + const params = new URLSearchParams(Array.from(searchParams.entries())); + params.set("path", newPath || schema.path); + router.push(`${pathname}?${params.toString()}`); + }, + [pathname, router, schema.path, searchParams], + ); + + const handleExpand = useCallback( + async (row: any) => { + if (!row) return; + const subRows = await fetchCollectionData( + row.isNode ? row.parentPath : row.path, + ); + if (subRows !== undefined) { + setData((currentData: any[]) => { + const updateNestedData = (items: any[]): any[] => { + return items.map((item: any) => { + if (item.path === row.path) return { ...item, subRows }; + if (item.subRows && Array.isArray(item.subRows)) { + const updatedSubRows = updateNestedData(item.subRows); + if (updatedSubRows !== item.subRows) { + return { ...item, subRows: updatedSubRows }; + } } - } - return item; - }); - }; - - return updateNestedData(currentData); - }); - } - }, [fetchCollectionData]); - - const loadingSkeleton = useMemo(() => ( - - - - - - - - - - - {[...Array(5)].map((_, index) => ( - - - - - + return item; + }); + }; + + return updateNestedData(currentData); + }); + } + }, + [fetchCollectionData], + ); + + const loadingSkeleton = useMemo( + () => ( +
- - - - - - - -
- - - - - - -
- - - - - {schema.view?.layout === 'tree' && ( - - )} -
-
+ + + + + + - ))} - -
+ + + + + + + +
- ), [schema.view?.layout]); + + + {[...Array(5)].map((_, index) => ( + + + + + + + + + + + +
+ + + + + {schema.view?.layout === "tree" && ( + + )} +
+ + + ))} + + + ), + [schema.view?.layout], + ); const addEntryHref = `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/new${ schema.view?.layout !== "tree" && path && path !== schema.path @@ -620,53 +786,84 @@ export function Collection({ }`; const breadcrumbNode = useMemo(() => { + const groupTrail: GroupTrailItem[] = Array.isArray(schema.groupTrail) + ? schema.groupTrail + : []; const normalizedRootPath = normalizePath(schema.path); const normalizedCurrentPath = normalizePath(collectionPath); - const relativePath = getRelativePath(normalizedCurrentPath, normalizedRootPath); - const segments = relativePath ? relativePath.split("/").filter(Boolean) : []; + const relativePath = getRelativePath( + normalizedCurrentPath, + normalizedRootPath, + ); + const segments = relativePath + ? relativePath.split("/").filter(Boolean) + : []; const entries = segments.map((segment, index) => ({ name: segment, - path: joinPathSegments([normalizedRootPath, segments.slice(0, index + 1).join("/")]), + path: joinPathSegments([ + normalizedRootPath, + segments.slice(0, index + 1).join("/"), + ]), })); const middleEntries = entries.length > 3 ? entries.slice(1, -1) : []; - const visibleEntries = entries.length > 3 - ? [entries[0], entries[entries.length - 1]] - : entries; + const visibleEntries = + entries.length > 3 ? [entries[0], entries[entries.length - 1]] : entries; return ( - + {groupTrail.map((group) => ( + + + {group.label || group.name} + + + + ))} + {entries.length > 0 ? ( - handleNavigate(schema.path)}> + handleNavigate(schema.path)} + > {schema.label || schema.name} ) : ( - {schema.label || schema.name} + + {schema.label || schema.name} + )} - {entries.length > 0 && } + {entries.length > 0 && } {entries.length > 3 && ( <> - + Show hidden segments {middleEntries.map((entry) => ( - handleNavigate(entry.path)} className="cursor-pointer"> + handleNavigate(entry.path)} + className="cursor-pointer" + > {entry.name} ))} - + )} @@ -674,90 +871,143 @@ export function Collection({ const isLast = index === visibleEntries.length - 1; return ( - + {isLast ? ( - {entry.name} + + {entry.name} + ) : ( - handleNavigate(entry.path)}> + handleNavigate(entry.path)} + > {entry.name} )} - {!isLast && } + {!isLast && } ); })} ); - }, [collectionPath, handleNavigate, schema.label, schema.name, schema.path]); - - const headerNode = useMemo(() => ( -
-
{breadcrumbNode}
- -
- ), [addEntryHref, breadcrumbNode, collectionPath, handleFolderCreate, handleTableSearchChange, name, schema.subfolders]); + }, [ + collectionPath, + handleNavigate, + schema.groupTrail, + schema.label, + schema.name, + schema.path, + ]); + + const headerNode = useMemo( + () => ( +
+
{breadcrumbNode}
+ 0 ? ( + + ) : undefined + } + collectionPath={collectionPath} + name={name} + showFolderCreate={schema.subfolders !== false} + onFolderCreate={handleFolderCreate} + onSearchChange={handleTableSearchChange} + /> +
+ ), + [ + addEntryHref, + breadcrumbNode, + collectionActions, + collectionPath, + config.branch, + config.owner, + config.repo, + handleFolderCreate, + handleTableSearchChange, + name, + schema.format, + schema.label, + schema.name, + schema.path, + schema.subfolders, + ], + ); useRepoHeader({ header: headerNode, }); - - const isLoading = !swrCollectionData && !swrCollectionError && data.length === 0; - - const contentNode = isLoading - ? loadingSkeleton - : error - ? ( -
- - - {error === "Not found" ? "Folder not found" : "Something went wrong"} - - {error === "Not found" - ? `The collection folder "${schema.path}" does not exist yet.` - : error} - - - - {error === "Not found" - ? Create folder - : ( - - Go to settings - - ) - } - - -
- ) - : ; - return ( -
- {contentNode} + const isLoading = + !swrCollectionData && !swrCollectionError && data.length === 0; + + const contentNode = isLoading ? ( + loadingSkeleton + ) : error ? ( +
+ + + + {error === "Not found" + ? "Folder not found" + : "Something went wrong"} + + + {error === "Not found" + ? `The collection folder "${schema.path}" does not exist yet.` + : error} + + + + {error === "Not found" ? ( + + Create folder + + ) : ( + + Go to settings + + )} + +
+ ) : ( + ); + + return
{contentNode}
; } diff --git a/components/empty-create.tsx b/components/empty-create.tsx index 5321db1e5..c653d33b3 100644 --- a/components/empty-create.tsx +++ b/components/empty-create.tsx @@ -102,7 +102,7 @@ const EmptyCreate = ({ }; return ( -
{!isReadonly && ( @@ -303,13 +304,13 @@ const ListField = ({ fieldName, renderFields, registerBeforeSubmitHook, - entryPath, + runBeforeSubmitHooks, }: { field: FieldWithReadonlyMeta; fieldName: string; renderFields: RenderFields; registerBeforeSubmitHook?: RegisterBeforeSubmitHook; - entryPath?: string; + runBeforeSubmitHooks?: () => Promise; }) => { const supportsItemCollapse = field.type === "object" || field.type === "block"; @@ -369,7 +370,7 @@ const ListField = ({ ); }, []); - const handleDragEnd = (event: DragEndEvent) => { + const handleDragEnd = async (event: DragEndEvent) => { if (isReadonly) return; const { active, over } = event; if (!over || active.id === over.id) return; @@ -379,12 +380,14 @@ const ListField = ({ if (oldIndex < 0 || newIndex < 0) return; + await runBeforeSubmitHooks?.(); setOpenStates((prev) => arrayMove(prev, oldIndex, newIndex)); move(oldIndex, newIndex); }; - const addItem = () => { + const addItem = async () => { if (isReadonly) return; + await runBeforeSubmitHooks?.(); append( field.type === "object" ? initializeState(field.fields, {}) @@ -394,14 +397,15 @@ const ListField = ({ }; const removeItem = useCallback( - (index: number) => { + async (index: number) => { if (isReadonly) return; + await runBeforeSubmitHooks?.(); remove(index); setOpenStates((prev) => prev.filter((_, currentIndex) => currentIndex !== index), ); }, - [isReadonly, remove], + [isReadonly, remove, runBeforeSubmitHooks], ); const [pendingRemoveIndex, setPendingRemoveIndex] = useState( null, @@ -470,11 +474,13 @@ const ListField = ({ )} {field.required && ( + Required )} {hasExplicitReadonly(field) && ( + Readonly )} @@ -491,11 +497,7 @@ const ListField = ({ className="ml-auto text-muted-foreground hover:text-foreground" onClick={() => toggleAll(isAllExpanded)} > - {isAllExpanded ? ( - - ) : ( - - )} + {isAllExpanded ? : } {isAllExpanded ? "Collapse all" : "Expand all"} ); @@ -529,7 +531,6 @@ const ListField = ({ onRequestRemove={handleRequestRemove} onRemoveConfirm={handleRemoveConfirm} onPendingRemoveChange={handlePendingRemoveChange} - entryPath={entryPath} /> ))} @@ -567,7 +568,7 @@ const BlocksField = forwardRef( isOpen, onToggleOpen, index, - entryPath, + keyPrefix, } = props; const isCollapsible = !!( @@ -645,10 +646,10 @@ const BlocksField = forwardRef(
) : ( -
+
(
{selectedBlockDefinition.type === "object" ? ( (() => { @@ -739,8 +737,9 @@ const BlocksField = forwardRef( selectedBlockDefinition.fields || [], fieldName, registerBeforeSubmitHook, + undefined, isReadonly, - entryPath, + keyPrefix, ); return renderedElements; })() @@ -756,10 +755,10 @@ const BlocksField = forwardRef( : selectedBlockDefinition } fieldName={fieldName} + keyPrefix={keyPrefix} renderFields={renderFields} registerBeforeSubmitHook={registerBeforeSubmitHook} showLabel={false} - entryPath={entryPath} /> )}
@@ -796,7 +795,7 @@ const ObjectField = forwardRef( isOpen = true, onToggleOpen = () => {}, index, - entryPath, + keyPrefix, } = props; const isCollapsible = !!( @@ -823,18 +822,18 @@ const ObjectField = forwardRef( ); return ( -
+
{isCollapsible && (
@@ -846,7 +845,7 @@ const ObjectField = forwardRef(
@@ -854,8 +853,9 @@ const ObjectField = forwardRef( field.fields || [], fieldName, registerBeforeSubmitHook, + undefined, Boolean(field.readonly), - entryPath, + keyPrefix, )}
@@ -875,6 +875,7 @@ const SingleField = ({ isOpen = true, toggleOpen = () => {}, index = 0, + keyPrefix, entryPath, }: { field: FieldWithReadonlyMeta; @@ -886,6 +887,7 @@ const SingleField = ({ isOpen?: boolean; toggleOpen?: () => void; index?: number; + keyPrefix?: string; entryPath?: string; }) => { const { @@ -922,11 +924,13 @@ const SingleField = ({ )} {field.required && ( + Required )} {hasExplicitReadonly(field) && ( + Readonly )} @@ -935,6 +939,7 @@ const SingleField = ({ ( @@ -976,6 +980,7 @@ const SingleField = ({ variant="secondary" className="text-muted-foreground" > + Required )} @@ -984,6 +989,7 @@ const SingleField = ({ variant="secondary" className="text-muted-foreground" > + Readonly )} @@ -1080,10 +1086,10 @@ const EntryForm = ({ fields: FieldWithReadonlyMeta[], parentName?: string, registerBeforeSubmitHook?: RegisterBeforeSubmitHook, + runBeforeSubmitHooks?: () => Promise, inheritedReadonly = false, - entryPathProp?: string, + keyPrefix?: string, ): React.ReactNode[] => { - const currentEntryPath = entryPathProp ?? entryPath; return fields.map((field) => { if (!field || field.hidden) return null; const effectiveField = @@ -1093,6 +1099,9 @@ const EntryForm = ({ const currentFieldName = parentName ? `${parentName}.${effectiveField.name}` : effectiveField.name; + const currentFieldKey = keyPrefix + ? `${keyPrefix}.${effectiveField.name}` + : currentFieldName; if ( effectiveField.list === true || @@ -1101,24 +1110,25 @@ const EntryForm = ({ ) { return ( ); } return ( ); }); @@ -1167,7 +1177,7 @@ const EntryForm = ({ {filePath}
)} - {renderFields(fields, undefined, registerBeforeSubmitHook)} + {renderFields(fields, undefined, registerBeforeSubmitHook, runBeforeValidationHooks)} ); diff --git a/components/entry/entry-history.tsx b/components/entry/entry-history.tsx index 6c0c0a501..13dc9cdee 100644 --- a/components/entry/entry-history.tsx +++ b/components/entry/entry-history.tsx @@ -87,9 +87,13 @@ export function EntryHistoryBlock({ export function EntryHistoryDropdown({ path, history, + triggerVariant = "ghost", + triggerSize = "icon", }: { path: string; history: EntryHistoryItem[]; + triggerVariant?: React.ComponentProps["variant"]; + triggerSize?: React.ComponentProps["size"]; }) { const { config } = useConfig(); @@ -98,7 +102,7 @@ export function EntryHistoryDropdown({ return ( - diff --git a/components/entry/entry.tsx b/components/entry/entry.tsx index 973bc94fa..8c0ef0a4e 100644 --- a/components/entry/entry.tsx +++ b/components/entry/entry.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { Fragment, useEffect, useState, useMemo, useCallback, useRef } from "react"; import type { ReactNode } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useConfig } from "@/contexts/config-context"; import { parseAndValidateConfig } from "@/lib/config"; import { requireApiSuccess } from "@/lib/api-client"; +import { getSchemaActions } from "@/lib/repo-actions"; import { generateFilename, getPrimaryField, @@ -26,7 +27,9 @@ import { EntryForm } from "./entry-form"; import { EntryHistoryDropdown } from "./entry-history"; import { EmptyCreate } from "@/components/empty-create"; import { FileOptions } from "@/components/file/file-options"; +import { RepoActionButtons } from "@/components/repo/repo-action-buttons"; import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; import { InputGroup, InputGroupAddon, @@ -70,6 +73,11 @@ type LintView = { }; }; +type GroupTrailItem = { + name: string; + label?: string | null; +}; + export function Entry({ name = "", path: initialPath, @@ -357,22 +365,7 @@ export function Entry({ if (data.data.sha !== sha) setSha(data.data.sha); if (submitStartChangeVersion === changeVersionRef.current) { - const savedContentObject = schema?.list === true - ? contentObject.listWrapper - : contentObject; - const savedContentSnapshot = JSON.parse(JSON.stringify(savedContentObject)); - setEntry((prevEntry) => ( - prevEntry - ? { ...prevEntry, contentObject: savedContentSnapshot } - : prevEntry - )); setHasRegisteredChanges(false); - if (entryApiUrl) { - void mutate(entryApiUrl, { - ...data.data, - contentObject: savedContentSnapshot, - }, { revalidate: false }); - } } if (!path && schemaType === "collection") router.push(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/edit/${encodeURIComponent(data.data.path)}`); @@ -462,15 +455,45 @@ export function Entry({ }, [config.branch, config.owner, config.repo, entryApiUrl, mutate, name, router, schemaType]); const breadcrumbNode = useMemo(() => { - if (schemaType !== "collection" || !schema) { + if (!schema) { return {displayTitle}; } + const groupTrail: GroupTrailItem[] = Array.isArray(schema.groupTrail) + ? schema.groupTrail + : []; + + if (schemaType !== "collection") { + return ( + <> + {groupTrail.map((group) => ( + + + {group.label || group.name} + + + + ))} + + {displayTitle} + + + ); + } + const rootLabel = schema.label || schema.name || name; if (!path) { return ( <> + {groupTrail.map((group) => ( + + + {group.label || group.name} + + + + ))} @@ -501,6 +524,14 @@ export function Entry({ return ( <> + {groupTrail.map((group) => ( + + + {group.label || group.name} + + + + ))} @@ -553,11 +584,61 @@ export function Entry({ ); }, [config.branch, config.owner, config.repo, displayTitle, name, path, schema, schemaType]); const showHeaderActions = error !== "Not found"; + const headerActionsNode = useMemo(() => { + if (!schema || !path) return null; + + if (schemaType === "file") { + const fileActions = getSchemaActions(schema); + if (fileActions.length === 0) return null; + + return ( + + ); + } + + if (schemaType === "collection" && entry) { + const entryActions = getSchemaActions(schema, "entry"); + if (entryActions.length === 0) return null; + + return ( + + ); + } + + return null; + }, [config.branch, config.owner, config.repo, entry, path, schema, schemaType, sha]); const headerNode = useMemo(() => (
- + {breadcrumbNode} @@ -566,15 +647,22 @@ export function Entry({
{showHeaderActions && (
+ {headerActionsNode} {path && ( historyData && historyData.length > 0 && !isLoading - ? - : + ? ( + + ) + : )} {path && ( - sha - ? ( - - - - ) - : + + {sha + ? ( + + + + ) + : + } + )}
)}
- ), [breadcrumbNode, handleDelete, handleRename, hasRegisteredChanges, headerMeta, historyData, isBusy, isFormDirty, isLoading, name, path, schemaType, sha, showHeaderActions]); + ), [breadcrumbNode, filenameChanged, filenameFieldMode, filenameValue, handleDelete, handleRename, hasRegisteredChanges, headerActionsNode, headerMeta, historyData, isBusy, isFilenameUnlocked, isFormDirty, isLoading, name, path, schemaType, sha, showFilenameField, showHeaderActions]); useRepoHeader({ header: headerNode }); @@ -702,8 +793,6 @@ export function Entry({ } } - const entryPath = path ? normalizePath(getParentPath(path)) : undefined; - return ( isLoading ? loadingSkeleton @@ -711,7 +800,6 @@ export function Entry({ fields={entryFields} contentObject={entryContentObject} onSubmit={onSubmit} - entryPath={entryPath} filePath={ showFilenameField ? @@ -747,6 +835,7 @@ export function Entry({ : undefined } + entryPath={path} onDirtyChange={setIsFormDirty} onChangeRegistered={() => { changeVersionRef.current += 1; diff --git a/components/media/media-upload.tsx b/components/media/media-upload.tsx index 72851902c..a9d4ce289 100644 --- a/components/media/media-upload.tsx +++ b/components/media/media-upload.tsx @@ -2,7 +2,7 @@ import { useRef, cloneElement, useMemo, useCallback, createContext, useContext, useState } from "react"; import { useConfig } from "@/contexts/config-context"; -import { getFileExtension, generateRandomUploadName, joinPathSegments } from "@/lib/utils/file"; +import { getUploadFileName, joinPathSegments } from "@/lib/utils/file"; import { toast } from "sonner"; import { getSchemaByName } from "@/lib/schema"; import { cn } from "@/lib/utils"; @@ -25,7 +25,7 @@ interface MediaUploadProps { media?: string; extensions?: string[]; multiple?: boolean; - rename?: boolean; + rename?: boolean | "safe" | "random"; disabled?: boolean; } @@ -66,11 +66,10 @@ function MediaUploadRoot({ children, path, onUpload, media, extensions, multiple const handleFiles = useCallback(async (files: File[]) => { try { for (const file of files) { - const effectiveRename = rename ?? Boolean(configMedia?.rename); - const extension = getFileExtension(file.name); - const uploadFilename = effectiveRename - ? generateRandomUploadName(extension) - : file.name; + const uploadFilename = getUploadFileName( + file.name, + rename ?? configMedia?.rename, + ); const uploadPromise = (async () => { const content = await new Promise((resolve, reject) => { diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index 5e62480fd..926af1a21 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -1,9 +1,10 @@ "use client"; -import { Fragment, memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, memo, type ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useConfig } from "@/contexts/config-context"; +import { RepoActionButtons } from "@/components/repo/repo-action-buttons"; import { extensionCategories, getFileSize, @@ -39,6 +40,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { requireApiSuccess } from "@/lib/api-client"; +import { getSchemaActions } from "@/lib/repo-actions"; import type { FileSaveData, MediaItem } from "@/types/api"; import useSWR, { useSWRConfig } from "swr"; import { @@ -54,27 +56,24 @@ import { } from "lucide-react"; function MediaHeaderActions({ + actionNode, mediaName, path, onFolderCreate, }: { + actionNode?: ReactNode; mediaName: string; path: string; onFolderCreate: (entry: unknown) => void; }) { return (
- - - + {actionNode}
- @@ -82,6 +81,12 @@ function MediaHeaderActions({ Create folder + + +
); } @@ -229,6 +234,10 @@ const MediaView = ({ return allowedExtensions || []; }, [extensions, mediaConfig?.extensions]); + const mediaActions = useMemo( + () => getSchemaActions(mediaConfig), + [mediaConfig], + ); const filteredExtensionsSet = useMemo( () => new Set((filteredExtensions || []).map((ext: string) => ext.toLowerCase())), @@ -564,13 +573,29 @@ const MediaView = ({
{breadcrumbNode}
0 ? ( + + ) : undefined} mediaName={mediaConfig.name} path={path} onFolderCreate={handleFolderCreate} />
- ), [breadcrumbNode, filteredExtensions, handleFolderCreate, handleUpload, mediaConfig.name, path]); + ), [breadcrumbNode, config.branch, config.owner, config.repo, filteredExtensions, handleFolderCreate, handleUpload, mediaActions, mediaConfig.input, mediaConfig.label, mediaConfig.name, mediaConfig.output, path]); useOptionalRepoHeader( { header: headerNode }, @@ -598,7 +623,7 @@ const MediaView = ({ Open configuration @@ -632,7 +657,7 @@ const MediaView = ({ {error} - + ); @@ -690,6 +715,22 @@ const MediaView = ({
0 ? ( + + ) : undefined} mediaName={mediaConfig.name} path={path} onFolderCreate={handleFolderCreate} diff --git a/components/providers.tsx b/components/providers.tsx index 7b125482e..39acbe0ce 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -1,6 +1,7 @@ "use client"; import { ThemeProvider } from "@/components/theme-provider"; +import { ActionToastProvider } from "@/contexts/action-toast-context"; import { UserProvider } from "@/contexts/user-context"; import { TooltipProvider } from "@/components/ui/tooltip"; import { User } from "@/types/user"; @@ -15,9 +16,11 @@ export function Providers({ children, user }: { children: React.ReactNode, user: > - {children} + + {children} + ); -} \ No newline at end of file +} diff --git a/components/repo/repo-action-buttons.tsx b/components/repo/repo-action-buttons.tsx new file mode 100644 index 000000000..2dc8cc084 --- /dev/null +++ b/components/repo/repo-action-buttons.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { formatDistanceToNowStrict } from "date-fns"; +import { + CircleCheck, + CirclePlay, + CircleX, + EllipsisVertical, + Loader, +} from "lucide-react"; +import { toast } from "sonner"; +import { useActionToasts } from "@/contexts/action-toast-context"; +import { useUser } from "@/contexts/user-context"; +import { hasGithubIdentity } from "@/lib/authz"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { requireApiSuccess } from "@/lib/api-client"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + formatActionRunState, + isActionRunActive, + type RepoActionField, + type ActionRunSummary, + type RepoActionConfig, +} from "@/lib/repo-actions"; +import { + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type RepoActionButtonsProps = { + actions: RepoActionConfig[]; + owner: string; + repo: string; + refName: string; + contextType: string; + contextName?: string | null; + contextPath?: string | null; + contextData?: Record; + layout?: "header" | "sidebar"; +}; + +const getRunIcon = (run: ActionRunSummary | null | undefined) => { + if (!run || isActionRunActive(run)) { + return ; + } + if (run.conclusion === "success") { + return ; + } + return ; +}; + +const getPrimaryIcon = (isBusy: boolean) => ( + isBusy ? : +); + +const formatRunLine = (run: ActionRunSummary) => { + const createdAt = run.createdAt ? new Date(run.createdAt) : null; + const secondary = run.triggeredByName ? `by ${run.triggeredByName}` : null; + if (!createdAt || Number.isNaN(createdAt.getTime())) { + return ( + + Unknown time + {secondary && ( + {secondary} + )} + + ); + } + + const relative = formatDistanceToNowStrict(createdAt, { addSuffix: true }); + + return ( + + {relative} + {secondary && ( + {secondary} + )} + + ); +}; + +const getDefaultFieldValues = (fields: RepoActionField[] | undefined) => { + return (fields ?? []).reduce>((accumulator, field) => { + if (field.default != null) { + accumulator[field.name] = field.default; + return accumulator; + } + accumulator[field.name] = field.type === "checkbox" ? false : ""; + return accumulator; + }, {}); +}; + +const isFieldValueValid = (field: RepoActionField, value: string | number | boolean | undefined) => { + if (!field.required) return true; + if (field.type === "checkbox") return value === true; + if (field.type === "number") return value !== "" && value != null; + return typeof value === "string" && value.trim().length > 0; +}; + +export function RepoActionButtons({ + actions, + owner, + repo, + refName, + contextType, + contextName = null, + contextPath = null, + contextData = {}, + layout = "header", +}: RepoActionButtonsProps) { + const { user } = useUser(); + const { trackActionRun } = useActionToasts(); + const isGithubUser = hasGithubIdentity(user); + const [runsByAction, setRunsByAction] = useState>({}); + const [dispatching, setDispatching] = useState>({}); + const [dialogAction, setDialogAction] = useState(null); + const [dialogValues, setDialogValues] = useState>({}); + const refreshErrorToastIdRef = useRef(null); + + const actionNames = useMemo(() => actions.map((action) => action.name), [actions]); + + const loadRuns = useCallback(async () => { + if (actions.length === 0) return; + + const params = new URLSearchParams({ + actionNames: actionNames.join(","), + contextType, + }); + if (contextName) params.set("contextName", contextName); + if (contextPath) params.set("contextPath", contextPath); + + const response = await fetch(`/api/${owner}/${repo}/${encodeURIComponent(refName)}/actions?${params.toString()}`); + const payload = await requireApiSuccess<{ data: Record }>( + response, + "Failed to fetch action runs", + ); + setRunsByAction(payload.data); + return payload.data; + }, [actionNames, actions.length, contextName, contextPath, contextType, owner, refName, repo]); + + useEffect(() => { + void loadRuns(); + }, [loadRuns]); + + useEffect(() => { + if (actions.length === 0) return; + + const interval = window.setInterval(async () => { + try { + const latestRuns = await loadRuns(); + if (!latestRuns) return; + if (refreshErrorToastIdRef.current != null) { + toast.dismiss(refreshErrorToastIdRef.current); + refreshErrorToastIdRef.current = null; + } + + } catch (error) { + console.error(error); + if (refreshErrorToastIdRef.current == null) { + refreshErrorToastIdRef.current = toast.error("Couldn’t refresh action status. Retrying…"); + } + } + }, 4000); + + return () => window.clearInterval(interval); + }, [actions, loadRuns]); + + const dispatchAction = useCallback(async ( + action: RepoActionConfig, + inputValues: Record = {}, + ) => { + const toastId = toast.loading(`Starting "${action.label}"…`); + + setDispatching((current) => ({ ...current, [action.name]: true })); + + try { + const response = await fetch(`/api/${owner}/${repo}/${encodeURIComponent(refName)}/actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + context: { + kind: contextType, + name: contextName, + path: contextPath, + data: contextData, + }, + inputs: inputValues, + }), + }); + const payload = await requireApiSuccess<{ data: { id: number } }>( + response, + "Failed to dispatch action", + ); + trackActionRun({ + runId: payload.data.id, + owner, + repo, + refName, + actionLabel: action.label, + toastId, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to dispatch action.", { id: toastId }); + } finally { + setDispatching((current) => ({ ...current, [action.name]: false })); + } + }, [contextData, contextName, contextPath, contextType, owner, refName, repo, trackActionRun]); + + const handleActionClick = useCallback((action: RepoActionConfig) => { + const shouldConfirm = action.confirm !== false; + const hasFields = Boolean(action.fields?.length); + if (!shouldConfirm && !hasFields) { + void dispatchAction(action); + return; + } + + setDialogAction(action); + setDialogValues(getDefaultFieldValues(action.fields)); + }, [dispatchAction]); + + const isDialogSubmitDisabled = useMemo(() => { + if (!dialogAction?.fields?.length) return false; + return dialogAction.fields.some((field) => !isFieldValueValid(field, dialogValues[field.name])); + }, [dialogAction, dialogValues]); + + const handleDialogSubmit = useCallback(() => { + if (!dialogAction) return; + void dispatchAction(dialogAction, dialogValues); + setDialogAction(null); + setDialogValues({}); + }, [dialogAction, dialogValues, dispatchAction]); + + const renderDialogField = (field: RepoActionField) => { + const value = dialogValues[field.name]; + const fieldId = `action-field-${field.name}`; + + if (field.type === "textarea") { + return ( +