Skip to content
Open
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
8 changes: 5 additions & 3 deletions .agents/skills/typescript-monorepo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ strict.

- Shared compiler options live in `tsconfig.options.json`.
- Root `tsconfig.json` manages project references across the monorepo.
- Typechecking uses `tsgo` and runs through moon: `moonx <project>:typecheck`
(moon builds workspace dependencies first, since types resolve through each
dependency's built dist).
- Typechecking uses TypeScript 7's native `tsc` (the successor to the `tsgo`
preview) and runs through moon: `moonx <project>:typecheck` (moon builds
workspace dependencies first, since types resolve through each dependency's
built dist). Next runs the same `tsc` for its build-time check through
`experimental.useTypeScriptCli`, which is on by default since Next 16.3.

## Project References

Expand Down
4 changes: 2 additions & 2 deletions .moon/tasks/bun-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ inheritedBy:
# defined in project moon.yml files):
# - package.json: manifest changes (deps, exports) must re-trigger tasks.
# - /tsconfig.options.json: every package tsconfig extends the shared root
# options, and several builds emit type declarations (tsgo --build, tsdown
# options, and several builds emit type declarations (tsc --build, tsdown
# dts, next build), so compiler-option changes must re-key everything or
# stale declarations get restored from cache.
implicitInputs:
Expand All @@ -30,7 +30,7 @@ tasks:
# (no TypeScript project references between packages), so typechecking a
# project requires its workspace dependencies to be built first.
typecheck:
command: 'tsgo --noEmit --pretty'
command: 'tsc --noEmit --pretty'
deps:
- '^:build'
inputs:
Expand Down
2 changes: 2 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
".next",
".source",
".vercel",
"apps/**/AGENTS.md",
"apps/**/CLAUDE.md",
"apps/docs/public/r/*",
"apps/docs/next-env.d.ts",
"packages/theme/themes/*.json",
Expand Down
12 changes: 12 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
],

"react/error-boundaries": "error",
"react/globals": "error",
"react/immutability": "error",
"react/incompatible-library": "error",
"react/preserve-manual-memoization": "error",
"react/purity": "error",
"react/refs": "error",
"react/set-state-in-effect": "error",
"react/set-state-in-render": "error",
"react/static-components": "error",
"react/use-memo": "error",

"sort-imports": [
"error",
{
Expand Down
9 changes: 9 additions & 0 deletions apps/diffshub/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions apps/diffshub/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
12 changes: 7 additions & 5 deletions apps/diffshub/components/DiffUrlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function DiffUrlForm({
}: DiffUrlFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [previousInitialUrl, setPreviousInitialUrl] = useState(initialUrl);
const [url, setURL] = useState(initialUrl);
const [validationError, setValidationError] = useState<string | null>(null);
// Tracks the input's viewport position when an error is shown so the portal
Expand All @@ -58,14 +59,15 @@ export function DiffUrlForm({
left: number;
} | null>(null);
// Preserves the last message so the popover still has content while fading out.
const lastErrorText = useRef<string | null>(null);
const [lastErrorText, setLastErrorText] = useState<string | null>(null);
// Prevents the onBlur restore from firing when blur is caused by Enter.
const isSubmittingRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (previousInitialUrl !== initialUrl) {
setPreviousInitialUrl(initialUrl);
setURL(initialUrl);
}, [initialUrl]);
}

useEffect(() => {
onUrlChange?.(url);
Expand Down Expand Up @@ -99,7 +101,7 @@ export function DiffUrlForm({
if (viewerHref == null) {
const rect = inputRef.current?.getBoundingClientRect();
if (rect != null) setErrorAnchor({ top: rect.bottom, left: rect.left });
lastErrorText.current = 'Please enter a valid URL';
setLastErrorText('Please enter a valid URL');
setValidationError('Please enter a valid URL');
return;
}
Expand Down Expand Up @@ -193,7 +195,7 @@ export function DiffUrlForm({
}}
>
<div className="bg-foreground absolute -top-1 left-3 size-2.5 rotate-45 rounded-[2px]" />
{lastErrorText.current}
{lastErrorText}
</div>,
document.body
)}
Expand Down
11 changes: 6 additions & 5 deletions apps/diffshub/components/DiffsHubFileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { type CSSProperties, memo, useEffect, useRef, useState } from 'react';

import type { FileTreePublicId } from '../../../packages/trees/dist/model/publicTypes';
import { ThemedFileTree } from './ThemedFileTree';
import { useLatestValueRef } from './useLatestValueRef';
import {
BASE_FILE_TREE_OPTIONS,
CODE_VIEW_FILE_TREE_ITEM_HEIGHT,
Expand Down Expand Up @@ -50,18 +51,18 @@ export const DiffsHubFileTree = memo(function DiffsHubFileTree({
onSelectItem,
source,
}: DiffsHubFileTreeProps) {
const sourceRef = useRef(source);
const sourceRef = useLatestValueRef(source);
const previousSourceRef = useRef(source);
const [initialVisibleRowCount] = useState(getInitialBatchSize);
sourceRef.current = source;
// `source.paths` aliases the streaming accumulator's live array, so it keeps
// growing on later publishes. The FileTree model consumes its path list
// exactly once via useFileTree's useState initializer; capture a bounded
// snapshot here so the first model build uses only what `pathCount`
// describes and so subsequent streaming re-renders don't re-slice the
// ever-growing live array.
const initialPathsRef = useRef<readonly string[] | null>(null);
initialPathsRef.current ??= source.paths.slice(0, source.pathCount);
const [initialPaths] = useState(() =>
source.paths.slice(0, source.pathCount)
);
const onSelectionChange = useStableCallback(
(selectedPaths: readonly FileTreePublicId[]) => {
if (selectedPaths.length !== 1 || onSelectItem == null) {
Expand All @@ -78,7 +79,7 @@ export const DiffsHubFileTree = memo(function DiffsHubFileTree({
const { model } = useFileTree({
...BASE_FILE_TREE_OPTIONS,
gitStatus: source.gitStatus,
paths: initialPathsRef.current,
paths: initialPaths,
sort: PRESERVE_INPUT_ORDER_SORT,
onSelectionChange,
itemHeight: CODE_VIEW_FILE_TREE_ITEM_HEIGHT,
Expand Down
11 changes: 5 additions & 6 deletions apps/diffshub/components/DiffsHubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,10 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({
);
});

function colorModeIcon(colorMode: ColorMode) {
if (colorMode === 'light') return IconColorLight;
if (colorMode === 'dark') return IconColorDark;
return IconColorAuto;
function renderColorModeIcon(colorMode: ColorMode, className: string) {
if (colorMode === 'light') return <IconColorLight className={className} />;
if (colorMode === 'dark') return <IconColorDark className={className} />;
return <IconColorAuto className={className} />;
}

interface ThemeDropdownProps {
Expand Down Expand Up @@ -357,7 +357,6 @@ function ThemeDropdown({
setLightThemeName,
themeDropdownStyle,
}: ThemeDropdownProps) {
const TriggerIcon = colorModeIcon(colorMode);
const [view, setView] = useState<'main' | 'light' | 'dark'>('main');
// Only offer a reset when at least one slot drifts from the default
// pierre pair, so the link stays out of the way until it's useful.
Expand All @@ -383,7 +382,7 @@ function ThemeDropdown({
title="Theme settings"
className={CHROME_ICON_BUTTON_CLASS}
>
<TriggerIcon className="size-4 md:size-3" />
{renderColorModeIcon(colorMode, 'size-4 md:size-3')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
Expand Down
47 changes: 42 additions & 5 deletions apps/diffshub/components/DiffsHubSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';

import { CHROME_ICON_BUTTON_CLASS } from './chromeButtonStyles';
Expand Down Expand Up @@ -59,6 +60,29 @@ type SidebarStatusPanel = 'diffStats' | 'systemMonitor';

const MOBILE_MEDIA_QUERY = '(max-width: 767px)';

// One MediaQueryList shared by the subscribe and snapshot readers below, so
// renders do not allocate a new list and the change listener is bound to the
// same object the snapshot reads.
let mobileMediaQueryList: MediaQueryList | undefined;
function getMobileMediaQueryList(): MediaQueryList {
mobileMediaQueryList ??= window.matchMedia(MOBILE_MEDIA_QUERY);
return mobileMediaQueryList;
}

function subscribeToMobileViewport(onChange: () => void): () => void {
const mediaQuery = getMobileMediaQueryList();
mediaQuery.addEventListener('change', onChange);
return () => mediaQuery.removeEventListener('change', onChange);
}

function getMobileViewportSnapshot(): boolean {
return getMobileMediaQueryList().matches;
}

function getServerMobileViewportSnapshot(): undefined {
return undefined;
}

interface DiffsHubSidebarProps {
className?: string;
commentSections: readonly DiffsHubSavedCommentItem[];
Expand Down Expand Up @@ -112,6 +136,13 @@ export const DiffsHubSidebar = memo(function DiffsHubSidebar({
);
const [activeStatusPanel, setActiveStatusPanel] =
useState<SidebarStatusPanel | null>('diffStats');
const isMobileViewport = useSyncExternalStore(
subscribeToMobileViewport,
getMobileViewportSnapshot,
getServerMobileViewportSnapshot
);
const [previousMobileOverlayOpen, setPreviousMobileOverlayOpen] =
useState(false);
const [fileTreeModel, setFileTreeModel] = useState<FileTree | null>(null);
// Inclusion filter: the statuses the tree should show. Empty means "no
// filter" — every file is shown — so the menu opens with nothing checked and
Expand Down Expand Up @@ -162,14 +193,20 @@ export const DiffsHubSidebar = memo(function DiffsHubSidebar({
});
}, []);

useEffect(() => {
if (mobileOverlayOpen && window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
// Reset the panel once when a mobile overlay opens, while still allowing the
// user to reopen it during that same overlay session.
if (
isMobileViewport !== undefined &&
previousMobileOverlayOpen !== mobileOverlayOpen
) {
setPreviousMobileOverlayOpen(mobileOverlayOpen);
if (mobileOverlayOpen && isMobileViewport) {
setActiveStatusPanel(null);
}
}, [mobileOverlayOpen]);
}

useEffect(() => {
if (!mobileOverlayOpen || !window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
if (!mobileOverlayOpen || isMobileViewport !== true) {
return undefined;
}

Expand All @@ -193,7 +230,7 @@ export const DiffsHubSidebar = memo(function DiffsHubSidebar({
codeViewScroll.style.overflow = previousCodeViewOverflow ?? '';
}
};
}, [mobileOverlayOpen, scrollRef]);
}, [isMobileViewport, mobileOverlayOpen, scrollRef]);

return (
<>
Expand Down
55 changes: 29 additions & 26 deletions apps/diffshub/components/ReviewUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';

import { DiffsHubHeader } from './DiffsHubHeader';
import { DiffsHubSidebar } from './DiffsHubSidebar';
import { DiffsHubStatusPanel } from './DiffsHubStatusPanel';
import { DiffsHubViewer } from './DiffsHubViewer';
import { ThemeSourceProvider } from './ThemeSourceProvider';
import { useGitHubDiffFileLoader } from './useGitHubDiffFileLoader';
import { useGitHubToken } from './useGitHubToken';
import { usePatchLoader } from './usePatchLoader';
import { useThemeCycle } from './useThemeCycle';
Expand All @@ -26,7 +27,6 @@ import {
themeController,
} from '@/components/themeController';
import { preloadAvatars } from '@/lib/annotation';
import { createGitHubDiffFileLoader } from '@/lib/githubDiffFileLoader';
import { removeSavedCommentSidebarEntry } from '@/lib/removeSavedCommentSidebarEntry';
import type { DarkThemeName, LightThemeName } from '@/lib/themeNames';
import type {
Expand All @@ -43,6 +43,20 @@ interface ReviewUIProps {
path: string;
}

// Hydration state changes only once, from the server snapshot to the client
// snapshot, so there is no external source to subscribe to.
function nullSubscription(): () => void {
return () => {};
}

function getClientHydrationState(): boolean {
return true;
}

function getServerHydrationState(): boolean {
return false;
}

export function ReviewUI({ domain, initialUrl, path }: ReviewUIProps) {
// Provide the diffshub-scoped theme context, then render the body BELOW it so
// the diffs hook + selection hook can read the controller context.
Expand Down Expand Up @@ -73,15 +87,13 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) {
token: githubToken,
tokenVersion: githubTokenVersion,
} = useGitHubToken();
const githubTokenRef = useRef(githubToken);
const githubTokenVersionRef = useRef(githubTokenVersion);
useEffect(() => {
githubTokenRef.current = githubToken;
}, [githubToken]);
useEffect(() => {
githubTokenVersionRef.current = githubTokenVersion;
}, [githubTokenVersion]);
const getGitHubToken = useCallback(() => githubTokenRef.current, []);
const { getGitHubToken, loadDiffFiles } = useGitHubDiffFileLoader({
domain,
hasGitHubToken,
path,
token: githubToken,
tokenVersion: githubTokenVersion,
});
// All theming state — color mode and the light/dark theme-name picks — lives
// in the single @pierre/theming controller (the same instance the app-wide
// ThemeProvider is bound to). Reading it here means picking Auto/Light/Dark
Expand All @@ -93,14 +105,15 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) {
// on the client, so useSyncExternalStore would surface them on the very first
// client render — but the server rendered the defaults. Gate every
// theme-derived value (rendered into inline chrome styles + the CodeView
// themeType) behind a client-mounted flag so the first client render matches
// themeType) behind a hydration snapshot so the first client render matches
// the SSR markup, then flips to the user's selection. This also keeps the
// long-lived WorkerPool and the CodeView from mounting against the default
// palette before the persisted values apply.
const [themesHydrated, setThemesHydrated] = useState(false);
useEffect(() => {
setThemesHydrated(true);
}, []);
const themesHydrated = useSyncExternalStore(
nullSubscription,
getClientHydrationState,
getServerHydrationState
);

const colorMode: ColorMode = themesHydrated ? themeState.mode : 'system';
const appResolvedTheme = themesHydrated
Expand Down Expand Up @@ -138,16 +151,6 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) {
const viewerRef = useRef<CodeViewHandle<CommentMetadata, undefined> | null>(
null
);
const loadDiffFiles = useMemo(
() =>
domain == null && hasGitHubToken
? createGitHubDiffFileLoader(path, {
getAuthVersion: () => githubTokenVersionRef.current,
getToken: () => githubTokenRef.current,
})
: undefined,
[domain, hasGitHubToken, path]
);
const handlePatchLoadStart = useCallback(() => {
setFileTreeOverlayOpen(false);
}, []);
Expand Down
Loading