Skip to content

Commit 2d935a0

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/func-cli-resolver
# Conflicts: # packages/db/migrations/meta/0282_snapshot.json # packages/db/migrations/meta/_journal.json
2 parents 6e5c8a1 + 5dbe95e commit 2d935a0

20 files changed

Lines changed: 19350 additions & 425 deletions

File tree

apps/docs/app/global.css

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -362,18 +362,32 @@ aside#nd-sidebar [data-radix-scroll-area-viewport] {
362362
min-height: var(--fd-docs-height) !important;
363363
}
364364

365-
/* Sidebar divider line — sticky within the docs layout box, so it ends where
366-
the layout does instead of bleeding into (or past) the footer below it.
367-
#nd-docs-layout is a CSS grid (see fumadocs' Container slot); a sticky
368-
pseudo-element stays in normal flow, so without an explicit grid-area it
369-
gets auto-placed into a real content cell and skews that cell's sizing.
370-
Spanning the full grid keeps it purely decorative/overlaid instead. */
365+
/* Pin the sidebar to the viewport instead of letting fumadocs' `sticky` do it.
366+
A sticky box is bottom-limited by its containing block, and #nd-docs-layout
367+
ends ~660px above the document bottom because the site footer is a sibling
368+
of the layout, not a grid child. So across the whole footer the sidebar gets
369+
pushed upward — and any content-height change while the reader is in that
370+
zone (expanding an FAQ row, say) makes it visibly jump. A fixed box ignores
371+
both the container's end and the document's height, so neither happens.
372+
373+
Safe because the grid columns are explicit (`0px 300px 1fr 268px 0px`), so
374+
removing the placeholder from flow leaves its track intact. `left`/`width`
375+
are restated because a fixed box no longer derives them from its grid cell,
376+
and `top`/`height` already come from fumadocs' own utility classes. */
377+
[data-sidebar-placeholder] {
378+
position: fixed !important;
379+
left: var(--sidebar-offset);
380+
width: var(--fd-sidebar-width);
381+
}
382+
383+
/* Sidebar divider line — pinned for the same reason, and so it stays glued to
384+
the sidebar's right edge. Being fixed takes it out of #nd-docs-layout's grid
385+
entirely, so it needs no grid placement and cannot skew a content cell; its
386+
position comes from `left`/`top` alone. */
371387
#nd-docs-layout::before {
372388
content: "";
373389
display: block;
374-
position: sticky;
375-
grid-row: 1 / -1;
376-
grid-column: 1 / -1;
390+
position: fixed;
377391
top: 92px; /* below navbar */
378392
height: calc(100dvh - 92px);
379393
left: calc(var(--sidebar-offset) + var(--fd-sidebar-width));

apps/docs/components/footer/footer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,16 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] })
130130
)
131131
}
132132

133+
/**
134+
* Site footer.
135+
*
136+
* `relative z-[22]` stacks it above the docs sidebar (z-20) and that sidebar's
137+
* divider (z-21), both of which are pinned to the viewport, so the footer slides
138+
* over them at the end of the page instead of being drawn through.
139+
*/
133140
export function Footer() {
134141
return (
135-
<footer className='mt-[120px] w-full border-[var(--border)] border-t bg-[var(--bg)] max-sm:mt-16 max-lg:mt-[88px]'>
142+
<footer className='relative z-[22] mt-[120px] w-full border-[var(--border)] border-t bg-[var(--bg)] max-sm:mt-16 max-lg:mt-[88px]'>
136143
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
137144
<nav
138145
aria-label='Footer navigation'
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({
9+
mockUpdateWorkspaceFileDimensions: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
13+
updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions,
14+
}))
15+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
16+
17+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
18+
const FILE = 'wf_abc123'
19+
const KEY = 'workspace/7727ef3f/screenshot.png'
20+
21+
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route'
22+
23+
const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) }
24+
25+
function buildRequest(body: unknown): NextRequest {
26+
return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, {
27+
method: 'PATCH',
28+
headers: { 'content-type': 'application/json' },
29+
body: JSON.stringify(body),
30+
})
31+
}
32+
33+
describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
37+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
38+
mockUpdateWorkspaceFileDimensions.mockResolvedValue(true)
39+
})
40+
41+
it('stores dimensions for a writer, keyed to the content version', async () => {
42+
const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext)
43+
expect(res.status).toBe(200)
44+
expect(await res.json()).toEqual({ success: true })
45+
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, {
46+
key: KEY,
47+
width: 1600,
48+
height: 900,
49+
})
50+
})
51+
52+
it('allows an admin', async () => {
53+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
54+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
55+
expect(res.status).toBe(200)
56+
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce()
57+
})
58+
59+
it('reports success:false when the content-version guard rejects the write (key changed)', async () => {
60+
mockUpdateWorkspaceFileDimensions.mockResolvedValue(false)
61+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
62+
expect(res.status).toBe(200)
63+
expect(await res.json()).toEqual({ success: false })
64+
})
65+
66+
it('rejects an unauthenticated caller before touching the DB', async () => {
67+
authMockFns.mockGetSession.mockResolvedValue(null)
68+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
69+
expect(res.status).toBe(401)
70+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
71+
})
72+
73+
it('rejects a read-only member (backfill requires write)', async () => {
74+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
75+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
76+
expect(res.status).toBe(403)
77+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
78+
})
79+
80+
it('rejects a missing key or non-positive / non-integer dimensions', async () => {
81+
for (const body of [
82+
{ width: 10, height: 10 }, // missing key
83+
{ key: KEY, width: 0, height: 10 },
84+
{ key: KEY, width: 10, height: -5 },
85+
{ key: KEY, width: 10.5, height: 10 },
86+
{ key: KEY, width: 10 },
87+
]) {
88+
const res = await PATCH(buildRequest(body), routeContext)
89+
expect(res.status).toBe(400)
90+
}
91+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
92+
})
93+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('WorkspaceFileDimensionsAPI')
12+
13+
/**
14+
* PATCH /api/workspaces/[id]/files/[fileId]/dimensions
15+
*
16+
* Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve
17+
* layout space before the image loads. Requires write permission. The write commits whenever the row
18+
* still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the
19+
* client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op.
20+
*/
21+
export const PATCH = withRouteHandler(
22+
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
23+
const session = await getSession()
24+
if (!session?.user?.id) {
25+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context)
29+
if (!parsed.success) return parsed.response
30+
const { id: workspaceId, fileId } = parsed.data.params
31+
const { key, width, height } = parsed.data.body
32+
33+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
34+
if (permission !== 'admin' && permission !== 'write') {
35+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
36+
}
37+
38+
try {
39+
// `written` is false when the content-version guard rejected the write (the row's storage key no
40+
// longer matches the key the client measured — the content was replaced since). That is not an
41+
// error; the client's next measurement, once its file list has the new key, persists correctly.
42+
const written = await updateWorkspaceFileDimensions(workspaceId, fileId, {
43+
key,
44+
width,
45+
height,
46+
})
47+
return NextResponse.json({ success: written })
48+
} catch (error) {
49+
logger.error('Failed to store workspace file dimensions', {
50+
workspaceId,
51+
fileId,
52+
error: getErrorMessage(error),
53+
})
54+
return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 })
55+
}
56+
}
57+
)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { Music } from '@sim/emcn/icons'
55
import dynamic from 'next/dynamic'
66
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
77
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
8-
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
8+
import {
9+
useWorkspaceFileBinary,
10+
useWorkspaceFileContent,
11+
useWorkspaceImageDimensionsAdapter,
12+
} from '@/hooks/queries/workspace-files'
913
import {
1014
createWorkspaceFileContentSource,
1115
type FileContentSource,
@@ -126,9 +130,10 @@ interface FileViewerProps {
126130

127131
export function FileViewer(props: FileViewerProps) {
128132
const { contentSource, workspaceId } = props
133+
const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId)
129134
const source = useMemo(
130-
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
131-
[contentSource, workspaceId]
135+
() => contentSource ?? createWorkspaceFileContentSource(workspaceId, imageDimensions),
136+
[contentSource, workspaceId, imageDimensions]
132137
)
133138
return (
134139
<FileContentSourceProvider value={source}>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
22
import { cn } from '@sim/emcn'
33
import { NodeSelection, Plugin } from '@tiptap/pm/state'
44
import type { ReactNodeViewProps } from '@tiptap/react'
55
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
6-
import { useFileContentSource } from '@/hooks/use-file-content-source'
6+
import { type ImageDimensions, useFileContentSource } from '@/hooks/use-file-content-source'
77
import { MarkdownImage } from './image-schema'
88
import { normalizeLinkHref } from './markdown-fidelity'
99
import { useEditorEditable } from './use-editor-editable'
1010

1111
const MIN_WIDTH = 64
1212

13+
/** A bare pixel count (`"640"`) that needs a `px` suffix, vs. an already-unit'd width (`"50%"`). */
14+
const BARE_PIXEL_WIDTH = /^\d+$/
15+
1316
/**
1417
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
1518
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
@@ -24,6 +27,11 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
2427
const [dragWidth, setDragWidth] = useState<number | null>(null)
2528
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
2629
const [failed, setFailed] = useState(false)
30+
/**
31+
* Intrinsic dimensions measured from the loaded image — holds the aspect-ratio box for THIS view when
32+
* the content source has no stored dimensions yet (the first-ever view of an image). Reset on src change.
33+
*/
34+
const [measuredDimensions, setMeasuredDimensions] = useState<ImageDimensions | null>(null)
2735
const attrs = node.attrs as {
2836
src?: string
2937
alt?: string
@@ -33,7 +41,16 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
3341
}
3442

3543
useEffect(() => () => dragAbortRef.current?.abort(), [])
36-
useEffect(() => setFailed(false), [attrs.src])
44+
45+
// Reset the load-failure flag and this-session measurement when the src changes — adjusted during
46+
// render (not in an effect) so the previous image's aspect-ratio box never paints for a frame. A `key`
47+
// remount isn't available here: TipTap owns this node view's instantiation.
48+
const [prevSrc, setPrevSrc] = useState(attrs.src)
49+
if (prevSrc !== attrs.src) {
50+
setPrevSrc(attrs.src)
51+
setFailed(false)
52+
setMeasuredDimensions(null)
53+
}
3754

3855
const startResize = (event: React.PointerEvent) => {
3956
event.preventDefault()
@@ -69,16 +86,34 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
6986
}
7087

7188
const committedWidth = attrs.width
72-
? /^\d+$/.test(attrs.width)
89+
? BARE_PIXEL_WIDTH.test(attrs.width)
7390
? `${attrs.width}px`
7491
: attrs.width
7592
: undefined
76-
const widthStyle =
93+
// Stored intrinsic dimensions reserve the box on the very first render. Memoized on the src (not the
94+
// live drag width) so a resize drag never re-scans the file list. Falls back to what we measured on
95+
// load this session for a first-ever view the metadata hasn't caught up on.
96+
const storedDimensions = useMemo(
97+
() => source.getImageDimensions?.(attrs.src) ?? null,
98+
[source, attrs.src]
99+
)
100+
// The browser's post-load measurement is authoritative — EXIF-corrected, and correct even when the
101+
// stored value is stale (e.g. left over after the file's content was replaced) — so it wins once
102+
// available; stored metadata only reserves the box pre-load. Equal in the common case, so no shift.
103+
const intrinsicDimensions = measuredDimensions ?? storedDimensions
104+
const displayWidth =
77105
dragWidth !== null
78-
? { width: `${dragWidth}px` }
79-
: committedWidth
80-
? { width: committedWidth }
81-
: undefined
106+
? `${dragWidth}px`
107+
: (committedWidth ?? (intrinsicDimensions ? `${intrinsicDimensions.width}px` : undefined))
108+
// width + aspect-ratio (with `max-w-full`/`h-auto` from the class list) reserves a responsive box the
109+
// image can't reflow into, per the CLS-avoidance pattern for known-ratio responsive images. React drops
110+
// the undefined keys, so an unmeasured image simply gets no reservation (its prior behavior).
111+
const imageStyle: CSSProperties = {
112+
width: displayWidth,
113+
aspectRatio: intrinsicDimensions
114+
? `${intrinsicDimensions.width} / ${intrinsicDimensions.height}`
115+
: undefined,
116+
}
82117

83118
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
84119
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
@@ -99,11 +134,28 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
99134
// the resize button sits outside this element, so it keeps its own pointer behavior.)
100135
draggable={editable}
101136
data-drag-handle={editable ? '' : undefined}
102-
style={widthStyle}
137+
style={imageStyle}
103138
onError={() => setFailed(true)}
104-
onLoad={() => setFailed(false)}
139+
onLoad={(event) => {
140+
setFailed(false)
141+
const { naturalWidth, naturalHeight } = event.currentTarget
142+
if (naturalWidth <= 0 || naturalHeight <= 0) return
143+
// The browser's measurement is authoritative. Reserve from it and persist whenever the stored
144+
// metadata is absent or disagrees (EXIF-rotated, or stale after a content swap), so a wrong value
145+
// self-corrects instead of sticking. Compare the memoized `storedDimensions` the render uses, NOT
146+
// a fresh cache read — the memo is non-reactive, and this keeps the guard consistent with render.
147+
if (
148+
storedDimensions &&
149+
storedDimensions.width === naturalWidth &&
150+
storedDimensions.height === naturalHeight
151+
) {
152+
return
153+
}
154+
setMeasuredDimensions({ width: naturalWidth, height: naturalHeight })
155+
source.reportImageDimensions?.(attrs.src, { width: naturalWidth, height: naturalHeight })
156+
}}
105157
className={cn(
106-
'block max-w-full rounded-lg border border-[var(--border)]',
158+
'block h-auto max-w-full rounded-lg border border-[var(--border)]',
107159
editable && 'cursor-grab',
108160
failed &&
109161
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'

0 commit comments

Comments
 (0)