Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e435280
feat: NavbarExtra
Vtec234 May 13, 2026
8b7d48c
feat: canAccessProject util
Vtec234 May 13, 2026
870ff44
refactor: sseStreamResponse
Vtec234 May 14, 2026
f5e99ef
fix: sse close
Vtec234 May 14, 2026
c4d3fbe
feat: initial awareness backend
Vtec234 May 14, 2026
fe54d77
feat: workspace mdata
Vtec234 May 14, 2026
2bd1b42
feat: share selections
Vtec234 May 14, 2026
d647551
feat: awareness navbar
Vtec234 May 14, 2026
9e53805
feat: remote cursor indicators
Vtec234 May 14, 2026
e7c639c
chore: logWithPrefix
Vtec234 May 26, 2026
7ba6781
feat: add proposed API
Vtec234 May 26, 2026
827cfc6
feat: case-based local edit handling
Vtec234 May 26, 2026
9befdf7
fix: vscodevim check
Vtec234 May 26, 2026
c92ef6e
feat: try to deduplicate colors
Vtec234 May 27, 2026
4e802ff
feat: selection styles
Vtec234 May 27, 2026
4c34228
feat: panel scaffolding
Vtec234 May 27, 2026
98c6dd4
feat: show online users
Vtec234 May 27, 2026
351787c
feat: online user indicators
Vtec234 May 27, 2026
824e204
fix: clear awareness state
Vtec234 May 27, 2026
0a92ee0
chore: rm awareness from navbar
Vtec234 May 27, 2026
31018e6
doc: link
Vtec234 May 27, 2026
973477d
fix: unify user sessions in panel
Vtec234 May 28, 2026
9e72352
feat: sync edit strategy
Vtec234 May 29, 2026
0034f49
chore: warn on conflicting extensions
Vtec234 May 30, 2026
c93f72d
feat: sound edit reconciliation algo
Vtec234 May 30, 2026
b205901
fix: shorten grace period
Vtec234 May 30, 2026
b4e847c
chore: move
Vtec234 May 30, 2026
2af7716
feat: write to disk on shutdown
Vtec234 May 30, 2026
ccf6704
chore: decouple
Vtec234 May 30, 2026
15d1fb8
fix: bundle tests
Vtec234 May 31, 2026
59ffc03
feat: collab edit tests
Vtec234 May 31, 2026
8bcd6df
ci: run tests
Vtec234 May 31, 2026
6037c5f
ci: try listening on localhost
Vtec234 May 31, 2026
eda9c83
feat: configurable timeout
Vtec234 Jun 1, 2026
d702b7a
chore: lint
Vtec234 Jun 1, 2026
1c2084a
doc: diagram
Vtec234 Jun 1, 2026
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
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ jobs:

- run: make container

# TODO: tests
# Virtual display for VS Code tests
- run: sudo apt-get update && sudo apt-get install --yes xvfb

- run: xvfb-run --auto-servernum npm --workspace vscode-workbench run test
15 changes: 13 additions & 2 deletions collab-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,18 @@ server.httpServer.listen(socketPath, () => {

await Promise.race([once(process, 'SIGINT'), once(process, 'SIGQUIT'), once(process, 'SIGTERM')])
console.log('Hocuspocus shutting down..')

// Persist open documents to disk.
await Promise.all(
[...server.hocuspocus.documents.values()].map(async doc => {
try {
await fs.writeFile(checkedToDiskPath(doc.name), doc.getText(YTEXT_KEY).toString())
console.log(`Saved '${doc.name}' to disk`)
} catch (e) {
console.error(`Failed to save '${doc.name}' to disk:`, e)
}
}),
)

await server.destroy()
db.close()

// TODO: ensure writes are flushed to disk.
22 changes: 13 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"lint": "prettier --check . && eslint"
},
"dependencies": {
"@hocuspocus/provider": "^4.0.0",
"@prisma/adapter-better-sqlite3": "^7.7.0",
"@prisma/client": "^7.7.0",
"better-auth": "^1.6.2",
Expand All @@ -28,13 +29,15 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"swr": "^2.4.1",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"babel-plugin-react-compiler": "1.0.0",
"concurrently": "^9.2.1",
"eslint": "^9",
Expand Down
10 changes: 2 additions & 8 deletions src/app/AvatarMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client'

import AvatarIcon from '@/app/components/AvatarIcon'
import authClient from '@/lib/auth-client'
import { ConfigCtx } from '@/lib/contexts'
import { setIsAdmin } from '@/lib/server/actions'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useContext } from 'react'
Expand All @@ -19,13 +19,7 @@ export default function AvatarMenu() {
<>
{user.isAdmin && <span className='admin-badge'>admin</span>}
<div className='avatar-menu'>
<button className='avatar-btn'>
{user.image ? (
<Image src={user.image} alt={user.name} width={28} height={28} loading='eager' />
) : (
<span className='avatar-placeholder'>{user.name[0].toUpperCase()}</span>
)}
</button>
<AvatarIcon user={user} />
<div className='avatar-dropdown'>
<div className='avatar-dropdown-user'>{user.name}</div>
{user.isAdmin && <Link href='/admin'>Admin interface</Link>}
Expand Down
38 changes: 38 additions & 0 deletions src/app/NavbarExtra.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client'

import React, { createContext, type ReactNode, type RefObject, use, useLayoutEffect, useRef, useState } from 'react'

type Setter = (_: ReactNode) => void
const NavbarExtraSetCtx = createContext<RefObject<Setter> | null>(null)

export function NavbarExtraProvider({ children }: Readonly<{ children: ReactNode }>) {
const ref = useRef<Setter>(() => {
console.warn('useNavbarExtra called before <NavbarExtra /> mounted')
})
return <NavbarExtraSetCtx value={ref}>{children}</NavbarExtraSetCtx>
}

/** Renders extra contents of the navbar as set by specific pages.
* This component and the page that uses {@link SetNavbarExtra}
* must share a {@link NavbarExtraProvider} parent */
export function NavbarExtra() {
const [extra, setExtra] = useState<ReactNode>(null)
const ref = use(NavbarExtraSetCtx)!
useLayoutEffect(() => {
ref.current = setExtra
}, [ref, setExtra])
return extra
}

/** Sets extra contents of the navbar to its children. */
export function SetNavbarExtra({ children }: Readonly<{ children: ReactNode }>) {
const ref = use(NavbarExtraSetCtx)!
React.useEffect(() => {
ref.current(children)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
ref.current(null)
}
}, [ref, children])
return null
}
4 changes: 2 additions & 2 deletions src/app/[userName]/[projectName]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { requireAuth } from '@/lib/server/actions'
import { getDb } from '@/lib/server/db'
import { getEditorSessionManager } from '@/lib/server/editorSessions'
import { canAccessProject } from '@/lib/server/util'
import z from 'zod'

const zParams = z.object({
Expand Down Expand Up @@ -48,8 +49,7 @@ export default async function EditorSession({ params: params_ }: { params: Promi
const project = await db.project.findUnique({
where: { userId_name: { userId: owner.id, name: params.projectName } },
})
const isOwner = viewer.name === params.userName
if (!project || (!isOwner && !project.isPublic)) {
if (!project || !canAccessProject(viewer, project)) {
return <Error msg='Project not found' />
}

Expand Down
56 changes: 22 additions & 34 deletions src/app/api/setup-events/route.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,29 @@
import { getSeedState } from '@/lib/server/seed'
import { sseStreamResponse } from '@/lib/server/util'

export async function GET() {
const encoder = new TextEncoder()

// eslint-disable-next-line prefer-const
let interval: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream({
start(controller) {
let cursor = 0

interval = setInterval(() => {
const st = getSeedState()
while (cursor < st.events.length) {
const event = st.events[cursor++]
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
if (event.type === 'done' || event.type === 'error') {
clearInterval(interval)
controller.close()
return
}
}
if (!st.inProgress) {
clearInterval(interval)
controller.close()
}
}, 500)
},
cancel() {
clearInterval(interval)
},
const [response, send, close] = sseStreamResponse(() => {
clearInterval(interval)
})
let cursor = 0
interval = setInterval(() => {
const st = getSeedState()
while (cursor < st.events.length) {
const event = st.events[cursor++]
send(event)
if (event.type === 'done' || event.type === 'error') {
clearInterval(interval)
close()
return
}
}
if (!st.inProgress) {
clearInterval(interval)
close()
}
}, 500)

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
return response
}
14 changes: 14 additions & 0 deletions src/app/components/AvatarIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { User } from '@/lib/server/auth'
import Image from 'next/image'

export default function AvatarIcon({ user }: { user: Pick<User, 'name' | 'image'> }) {
return (
<button className='avatar-btn'>
{user.image ? (
<Image src={user.image} alt={user.name} width={28} height={28} loading='eager' />
) : (
<span className='avatar-placeholder'>{user.name[0].toUpperCase()}</span>
)}
</button>
)
}
28 changes: 16 additions & 12 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { connection } from 'next/server'
import { Suspense, type ReactNode } from 'react'
import AvatarMenu from './AvatarMenu'
import Breadcrumbs from './Breadcrumbs'
import { NavbarExtra, NavbarExtraProvider } from './NavbarExtra'

const openSans = Open_Sans({
subsets: ['latin'],
Expand Down Expand Up @@ -56,18 +57,21 @@ async function RootLayoutBody({
<html lang='en' className={openSans.className}>
{/* https://nextjs.org/docs/app/getting-started/server-and-client-components#interleaving-server-and-client-components */}
<ConfigCtx value={clientCfg}>
<body>
<nav>
<Link className='logo' href='/'>
<Image src='/static/lean-logo.svg' alt='Lean logo' width={70} height={16} loading='eager' />
<span className='logo-text'>Lean Workbench</span>
</Link>
<Breadcrumbs />
<span className='spacer'></span>
{serverCfg.isSetupComplete && <AvatarMenu />}
</nav>
<main style={{ maxWidth: '600px' }}>{children}</main>
</body>
<NavbarExtraProvider>
<body>
<nav>
<Link className='logo' href='/'>
<Image src='/static/lean-logo.svg' alt='Lean logo' width={70} height={16} loading='eager' />
<span className='logo-text'>Lean Workbench</span>
</Link>
<Breadcrumbs />
<span className='spacer'></span>
<NavbarExtra />
{serverCfg.isSetupComplete && <AvatarMenu />}
</nav>
<main style={{ maxWidth: '600px' }}>{children}</main>
</body>
</NavbarExtraProvider>
</ConfigCtx>
</html>
)
Expand Down
2 changes: 2 additions & 0 deletions src/lib/server/collabServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export class CollabServerHandle {
readonly uuid = crypto.randomUUID()
/** Directory in which `collab-server` places its files. */
readonly workDir: string = `/tmp/collab-server-${this.uuid}/`
/** Path to the `collab-server` UDS file. */
readonly socketPath: string = path.join(this.workDir, COLLAB_SOCKET_FILENAME)

constructor(
/** Project that this server manages. */
Expand Down
4 changes: 2 additions & 2 deletions src/lib/server/editorSessions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { User } from '@/lib/server/auth'
import type { User } from '@/lib/server/auth'
import { CollabServerHandle } from '@/lib/server/collabServer'
import { getWorkspacesDir } from '@/lib/server/config'
import { getDb } from '@/lib/server/db'
import { VscodeServerHandle } from '@/lib/server/vscodeServer'
import { Project } from '@/prisma/generated/client'
import type { Project } from '@/prisma/generated/client'
import path from 'node:path'
import { EventEmitter } from 'node:stream'
import 'server-only'
Expand Down
44 changes: 44 additions & 0 deletions src/lib/server/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Project } from '@/prisma/generated/client'
import fs from 'node:fs/promises'
import 'server-only'
import type { User } from './auth'

export interface ProcessInfo {
pid: number
Expand Down Expand Up @@ -61,3 +63,45 @@ export const BWRAP_ARGS =
export function bwrapProjectDir(projectName: string) {
return `/workspace/${projectName}/`
}

export function canAccessProject(user: User, project: Project) {
const isOwner = user.id === project.userId
return isOwner || project.isPublic
}

/** Returns `[response, send, close]`.
* See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events */
export function sseStreamResponse(onCancel?: () => void): [Response, (msg: object) => void, () => void] {
let send: (msg: object) => void = () => {}
let close: () => void = () => {}
let closed = false
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
send = msg => {
if (closed) return
controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`))
}
close = () => {
if (closed) return
closed = true
controller.close()
}
},
cancel() {
closed = true
if (onCancel) onCancel()
},
})

const response = new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})

return [response, send, close]
}
Loading
Loading