Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
For example, prefer one state of type `'loading' | { error: E } | { result: T }`
to three states `[loading, setLoading] = useState(); [error, setError] = useState(); [result, setResult] = useState()`.
- For UI actions that hit the server,
prefer `useServerAction` (`@/lib/client/util`) or (if `useServerAction` doesn't work) `useActionState`
over manually storing response/error state with `useState`.
prefer `useServerAction` (`@/lib/client/util`) or (if `useServerAction` doesn't work) `useActionState`
over manually storing response/error state with `useState`.
- To call Server Functions on mount (e.g. to fetch data), use SWR.
- RSCs must call `await io()` before accessing server state (`getConfig`, `getAuth`, etc),
unless they have already used a request-time API (`cookies`, `params`, etc).
Expand Down Expand Up @@ -83,7 +83,37 @@ Some details/consequences:
some of the Server Actions previously invoked over the network by the RCC
may now be possible to call directly in the RSC;
if no other RCC still needs these Server Actions, move them to the RSC file.


## Directory structure

- Imports may refer to relative paths like `./actions` and `./SomeComponent`,
but imports like `../actions` or `../components/SomeComponent` are not permitted.
- Absolute imports starting with `@/app/components` are allowed;
no other imports starting with `@/app` are allowed.
(Example: `import { ErrorBox } from '@/app/components/ErrorBox'` is allowed,
`import { AdminErrorBox } from '@/app/components/admin/AdminErrorBox'` is allowed,
`import { SetupErrorBox } from '@/app/setup/SetupErrorBox'` is not allowed.)

Actions will naturally start out in `actions.ts` files alongside the `page.tsx` file where they are used,
and components will naturally start out in `MyComponent.tsx` files alongside the `page.tsx` files where they are used;
however, as a consequence of these rules,
when the functionality is needed in a second location, actions or components may need to migrate to a new location.
Gathering components for a route into a subfolder (e.g. `./src/app/(settings)/admin/components`)
may also require actions to migrate into the `./src/lib` tree.

- Functionality that is used in multiple places goes in `./src/app/components` and its subfolders (for `.tsx` files)
and in `./src/lib` and its subfolders (for server actions and other non-component `.ts` code).
- Most `.tsx` files that aren't a part of Next's routing should principally introduce a component,
along with hooks and helpers for that component.
- A file principally introducing the `ErrorBox` component should be named `ErrorBox.tsx`.
- Default exports should only be used where expected by Next.js.
A component `ErrorBox` should be exported as a non-default export in `ErrorBox.tsx`.

## Linting

- Autofixable problems should generally be `warn`, not `error`:
errors indicate only problems that aren't deterministically addressed by the pre-commit hook.

## Git

- Don't commit or push, let the user do that. You can automate git worktree operations, though.
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ const eslintConfig = defineConfig([
'warn',
{ allowSameFolder: true, rootDir: 'src', prefix: '@' },
],
'no-restricted-imports': [
'error',
{
patterns: [
{
regex: '^@/app/(?!components/)',
message: 'Route folders are private; move shared code to @/app/components or @/lib.',
},
],
},
],
},
},
{
Expand Down
8 changes: 4 additions & 4 deletions src/app/AvatarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import { usePathname, useRouter } from 'next/navigation'
import { Button, Header, Menu, MenuItem, MenuSection, MenuTrigger, Popover } from 'react-aria-components'

import AvatarIcon from '@/app/components/AvatarIcon'
import MenuLinkItem from '@/app/components/MenuLinkItem'
import authClient, { githubSignin } from '@/lib/client/auth'
import { AvatarIcon } from '@/app/components/AvatarIcon'
import { MenuLinkItem } from '@/app/components/MenuLinkItem'
import { authClient, githubSignin } from '@/lib/client/auth'
import { useThrowToBoundary } from '@/lib/client/util'
import { useConfigCtx } from '@/lib/contexts'
import { setIsAdmin } from '@/lib/server/actions'

export default function AvatarMenu() {
export function AvatarMenu() {
const session = authClient.useSession()
const cfg = useConfigCtx()
const router = useRouter()
Expand Down
2 changes: 1 addition & 1 deletion src/app/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'

export default function Breadcrumbs() {
export function Breadcrumbs() {
const pathname = usePathname()
const segments = pathname.split('/').filter(c => c !== '')
return (
Expand Down
2 changes: 1 addition & 1 deletion src/app/[userName]/ProjectRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { startTransition, useState } from 'react'
import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'

import MenuLinkItem from '@/app/components/MenuLinkItem'
import { MenuLinkItem } from '@/app/components/MenuLinkItem'
import { useServerAction } from '@/lib/client/util'

import { deleteProject, type ProjectInfo, renameProject, toggleVisibility } from './actions'
Expand Down
4 changes: 2 additions & 2 deletions src/app/[userName]/[projectName]/publish/PublishCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useRouter } from 'next/navigation'
import { startTransition } from 'react'

import ErrorBox from '@/app/components/ErrorBox'
import TrackedCommandForm from '@/app/components/TrackedCommandForm'
import { ErrorBox } from '@/app/components/ErrorBox'
import { TrackedCommandForm } from '@/app/components/TrackedCommandForm'
import { useServerAction } from '@/lib/client/util'

import { startPublishing, unpublish } from './actions'
Expand Down
2 changes: 1 addition & 1 deletion src/app/[userName]/[projectName]/publish/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'
import { connection } from 'next/server'
import z from 'zod'

import ErrorBox from '@/app/components/ErrorBox'
import { ErrorBox } from '@/app/components/ErrorBox'
import { detectPublishable, PUBLISH_MANIFEST_FILE } from '@/lib/server/artifacts'
import { getConfig, isPublishingEnabled } from '@/lib/server/config'
import { getDb } from '@/lib/server/db'
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/components/AllowlistEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useRouter } from 'next/navigation'
import { startTransition, useRef } from 'react'

import { addAllowedUser, removeAllowedUser } from '@/app/admin/actions'
import { useServerAction } from '@/lib/client/util'
import { addAllowedUser, removeAllowedUser } from '@/lib/server/adminActions'

export function AllowlistEditor({ users }: { users: string[] }) {
const router = useRouter()
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/components/HealthMonitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import { startTransition, use, useState } from 'react'

import { fetchDiskUsage, fetchHealth, type SystemHealth } from '@/app/admin/actions'
import CatchySuspense from '@/app/components/CatchySuspense'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { useServerAction, useThrowingSWR } from '@/lib/client/util'
import { fetchDiskUsage, fetchHealth, type SystemHealth } from '@/lib/server/adminActions'

import { formatBytes, formatUptime } from './utils'

Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/components/OAuthConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import { useRouter } from 'next/navigation'
import { use, useState } from 'react'

import { updateOAuthConfig } from '@/app/admin/actions'
import CatchySuspense from '@/app/components/CatchySuspense'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { useServerAction } from '@/lib/client/util'
import { updateOAuthConfig } from '@/lib/server/adminActions'

interface OAuthConfigProps {
oauthConfigPromise: Promise<{ clientId: string }>
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/components/RegistrationModeControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useRouter } from 'next/navigation'
import { startTransition, useState } from 'react'

import { setRegistrationMode } from '@/app/admin/actions'
import { useServerAction } from '@/lib/client/util'
import { setRegistrationMode } from '@/lib/server/adminActions'
import type { RegistrationMode } from '@/lib/server/config'

const MODES: { value: RegistrationMode; label: string }[] = [
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/components/SessionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import { useRouter } from 'next/navigation'
import { startTransition } from 'react'

import { killEditorSession } from '@/app/admin/actions'
import ProjectLink from '@/app/components/ProjectLink'
import { ProjectLink } from '@/app/components/ProjectLink'
import { useServerAction } from '@/lib/client/util'
import { killEditorSession } from '@/lib/server/adminActions'
import type { EditorSessionInfo, UnknownEditorSession } from '@/lib/server/shardConnection'

export function SessionRow({ info }: { info: EditorSessionInfo | UnknownEditorSession }) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/components/SessionViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { io } from 'next/cache'

import CatchySuspense from '@/app/components/CatchySuspense'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { requireAdmin } from '@/lib/server/auth'
import { getShardCoordinator } from '@/lib/server/shardConnection'

Expand Down
8 changes: 4 additions & 4 deletions src/app/admin/components/TemplateManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { startTransition, use, useState } from 'react'
import { Button, Dialog, DialogTrigger, Heading, Modal } from 'react-aria-components'
import { type Key, Tab, TabList, TabPanel, TabPanels, Tabs } from 'react-aria-components'

import { availableTemplateSchemas, doTemplateCreation, editTemplateMetadata } from '@/app/admin/actions'
import CatchySuspense from '@/app/components/CatchySuspense'
import ErrorBox from '@/app/components/ErrorBox'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { ErrorBox } from '@/app/components/ErrorBox'
import { QuickFilterInput, useQuickFilter } from '@/app/components/QuickFilterInput'
import TrackedCommandForm from '@/app/components/TrackedCommandForm'
import { TrackedCommandForm } from '@/app/components/TrackedCommandForm'
import { useServerAction, useThrowingSWR } from '@/lib/client/util'
import { availableTemplateSchemas, doTemplateCreation, editTemplateMetadata } from '@/lib/server/adminActions'
import { type TemplateInfo } from '@/lib/server/projectTemplate'

interface TemplateManagementProps {
Expand Down
6 changes: 3 additions & 3 deletions src/app/admin/components/ToolchainManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { useRouter } from 'next/navigation'
import { use, useState } from 'react'
import z from 'zod'

import { doElanInstall, uninstallToolchainVersion } from '@/app/admin/actions'
import CatchySuspense from '@/app/components/CatchySuspense'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { QuickFilterInput, useQuickFilter } from '@/app/components/QuickFilterInput'
import TrackedCommandForm from '@/app/components/TrackedCommandForm'
import { TrackedCommandForm } from '@/app/components/TrackedCommandForm'
import { useServerAction, useThrowingSWR } from '@/lib/client/util'
import { doElanInstall, uninstallToolchainVersion } from '@/lib/server/adminActions'

interface ToolchainManagementProps {
installedToolchainsPromise: Promise<string[]>
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/components/UserRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useRouter } from 'next/navigation'
import { startTransition } from 'react'

import { deleteUser, toggleAdmin } from '@/app/admin/actions'
import { useServerAction } from '@/lib/client/util'
import { deleteUser, toggleAdmin } from '@/lib/server/adminActions'
import { type User } from '@/lib/server/auth'

export function UserRow({ user, isSelf }: { user: Pick<User, 'name' | 'id' | 'isAdmin'>; isSelf: boolean }) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/forbidden.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ErrorBox from '@/app/components/ErrorBox'
import { ErrorBox } from '@/app/components/ErrorBox'

export default function Forbidden() {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import CatchySuspense from '@/app/components/CatchySuspense'
import { CatchySuspense } from '@/app/components/CatchySuspense'
import { fetchHealth } from '@/lib/server/adminActions'
import { getOAuthConfig, requireAdmin } from '@/lib/server/auth'
import { getConfig } from '@/lib/server/config'
import { listInstalledToolchains } from '@/lib/server/elan'
import { listTemplates } from '@/lib/server/projectTemplate'

import { fetchHealth } from './actions'
import { AccessControl } from './components/AccessControl'
import { HealthMonitor } from './components/HealthMonitor'
import { OAuthConfig } from './components/OAuthConfig'
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/unauthorized.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AdminLogin from './components/AdminLogin'
import { AdminLogin } from '@/app/components/AdminLogin'

export default function Unauthorized() {
return <AdminLogin />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { adminEmail, MIN_ADMIN_PASSWORD_LENGTH } from '@leanprover/workbench-sha
import { useActionState } from 'react'
import z from 'zod'

import auth from '@/lib/client/auth'
import { authClient } from '@/lib/client/auth'

async function loginAction(_: string | null, formData: FormData) {
const password = String(formData.get('password') ?? '')
try {
await auth.signIn.email({ email: adminEmail, password })
await authClient.signIn.email({ email: adminEmail, password })
} catch (e) {
if (z.object({ error: z.object({ code: z.literal('INVALID_EMAIL_OR_PASSWORD') }) }).safeParse(e).success) {
return 'Incorrect password'
Expand All @@ -21,7 +21,7 @@ async function loginAction(_: string | null, formData: FormData) {
return null
}

export default function AdminLogin() {
export function AdminLogin() {
const [error, action, isPending] = useActionState(loginAction, null)

return (
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/AvatarIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Image from 'next/image'

import type { User } from '@/lib/server/auth'

export default function AvatarIcon({ user }: { user: Pick<User, 'name' | 'image'> }) {
export function AvatarIcon({ user }: { user: Pick<User, 'name' | 'image'> }) {
return (
<span>
{user.image ? (
Expand Down
4 changes: 2 additions & 2 deletions src/app/components/CatchySuspense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { catchError, type ErrorInfo } from 'next/error'
import { type ReactNode, Suspense } from 'react'
import z from 'zod'

import ErrorBox from './ErrorBox'
import { ErrorBox } from './ErrorBox'

function ErrorFallback(props: { fallback?: ReactNode | ((err: unknown) => ReactNode) }, { error, retry }: ErrorInfo) {
let displayedError: ReactNode
Expand Down Expand Up @@ -54,7 +54,7 @@ const ErrorBoundary = catchError(ErrorFallback)
* Keep in mind that, in production environments, the actual thrown error may originate from the server
* and so will be an unhelpful digest string with a generic "An error occurred in the Server Components blah blah blah" message.
*/
export default function CatchySuspense(props: {
export function CatchySuspense(props: {
loading: ReactNode
error?: ReactNode | ((err: unknown) => ReactNode)
children: ReactNode
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/ErrorBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default function ErrorBox({ children }: { children: React.ReactNode }) {
export function ErrorBox({ children }: { children: React.ReactNode }) {
return (
<div
style={{
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/MenuLinkItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { MenuItem, type MenuItemProps } from 'react-aria-components'
/**
* Next.js adapter for React Aria's MenuItems that should navigate with Link behavior via the Next router.
*/
export default function MenuLinkItem<Href extends string>({
export function MenuLinkItem<Href extends string>({
href,
...props
}: Omit<MenuItemProps, 'href' | 'render'> & { href: Route<Href> }) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/ProjectLink.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from 'next/link'

export default function ProjectLink({ ownerUsername, projectName }: { ownerUsername: string; projectName: string }) {
export function ProjectLink({ ownerUsername, projectName }: { ownerUsername: string; projectName: string }) {
return (
<>
<Link href={`/${ownerUsername}`}>{ownerUsername}</Link>
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/SimpleTTY.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface SimpleTTYProps {
* The auto-reconnection features of SSE are disabled; instead, an explicit
* user action is needed to reconnect.
*/
export default function SimpleTTY(props: SimpleTTYProps) {
export function SimpleTTY(props: SimpleTTYProps) {
const [attempt, setAttempt] = useState(0)
return <SimpleTTYSession key={attempt} reload={() => setAttempt(attempt => attempt + 1)} {...props} />
}
Expand Down
6 changes: 3 additions & 3 deletions src/app/components/TrackedCommandForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import { type CSSProperties, type ReactNode, startTransition, useCallback, useEffect, useState } from 'react'

import ErrorBox from '@/app/components/ErrorBox'
import SimpleTTY from '@/app/components/SimpleTTY'
import { ErrorBox } from '@/app/components/ErrorBox'
import { SimpleTTY } from '@/app/components/SimpleTTY'
import { useServerAction, useThrowToBoundary } from '@/lib/client/util'
import { isTrackedCommandAvailable, isTrackedCommandRunning } from '@/lib/server/actions'
import { type ActionResponse, type TrackedCommandExit, type TrackedCommandScope } from '@/lib/util'
Expand Down Expand Up @@ -49,7 +49,7 @@ type FormState =
* "Close" button will be replaced (or augmented, if `successButton.allowClose` is true) with
* a button that contains `successButton.text` and performs `successButton.action` on click.
*/
export default function TrackedCommandForm({
export function TrackedCommandForm({
streamCommandKey,
scope,
style,
Expand Down
2 changes: 1 addition & 1 deletion src/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect } from 'react'
import { useSWRConfig } from 'swr'

import ErrorBox from './components/ErrorBox'
import { ErrorBox } from '@/app/components/ErrorBox'

interface ErrorBoundaryProps {
error: Error & { digest?: string }
Expand Down
2 changes: 1 addition & 1 deletion src/app/forbidden.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ErrorBox from './components/ErrorBox'
import { ErrorBox } from '@/app/components/ErrorBox'

export default function Forbidden() {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { type ReactNode, Suspense } from 'react'

import { ConfigCtxProvider } from '@/app/components/ConfigCtxProvider'

import AvatarMenu from './AvatarMenu'
import Breadcrumbs from './Breadcrumbs'
import { AvatarMenu } from './AvatarMenu'
import { Breadcrumbs } from './Breadcrumbs'
import { NavbarExtra, NavbarExtraProvider } from './NavbarExtra'

const openSans = Open_Sans({
Expand Down
Loading
Loading