From bc0586a63b33e13471b4092176104d9d925a59a3 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sun, 9 Aug 2026 14:53:49 +0100 Subject: [PATCH 1/3] feat: wrap-lines option and sticky file header in the tree/tour viewer Two things made long files painful to read in tour mode: - the tree/tour file viewer did not wrap long lines, it only scrolled horizontally (the diff view already wrapped) - the file path scrolled away, so you had to scroll back up to know which file you were looking at Changes: - new `wrapLines` setting (default on, persisted in localStorage) exposed as "Wrap lines" in the options popover of both the diff and the tree views - wrapping is driven by `data-wrap-lines` on plus `.code-cell` / `.code-scroll` / `.code-table` classes, same approach as the theme, so no prop drilling down to every line - with wrapping off, code containers scroll horizontally and tables switch to `table-layout: auto` so split panes don't overlap - the tree/tour file viewer gets a sticky header with the file path, the tour line range and a copy-path button, mirroring the diff view's file header - tour scroll-to-highlight now offsets by the sticky header height Default behaviour for the diff view is unchanged: it already wrapped. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui/src/components/diff/diff-line.tsx | 2 +- packages/ui/src/components/diff/diff-page.tsx | 4 ++ .../ui/src/components/diff/file-block.tsx | 4 +- .../src/components/diff/hunk-block-split.tsx | 2 +- .../src/components/icons/wrap-text-icon.tsx | 18 ++++++ .../ui/src/components/layout/options-menu.tsx | 16 +++++- packages/ui/src/components/layout/toolbar.tsx | 6 ++ .../ui/src/components/tree/file-viewer.tsx | 55 ++++++++++++++++--- packages/ui/src/components/tree/tree-page.tsx | 9 ++- packages/ui/src/hooks/use-wrap-lines.ts | 34 ++++++++++++ packages/ui/src/styles/app.css | 30 ++++++++++ 11 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/components/icons/wrap-text-icon.tsx create mode 100644 packages/ui/src/hooks/use-wrap-lines.ts diff --git a/packages/ui/src/components/diff/diff-line.tsx b/packages/ui/src/components/diff/diff-line.tsx index e647b79..d0dbc7b 100644 --- a/packages/ui/src/components/diff/diff-line.tsx +++ b/packages/ui/src/components/diff/diff-line.tsx @@ -70,7 +70,7 @@ export function DiffLine(props: DiffLineProps) { {getPrefix(line.type)} - + {renderContent(line, syntaxTokens)} diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 9027ff9..df706b3 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useDiff } from '../../hooks/use-diff'; import { useInfo } from '../../hooks/use-info'; import { useTheme } from '../../hooks/use-theme'; +import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useKeyboard } from '../../hooks/use-keyboard'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useCommentActions } from '../../hooks/use-comment-actions'; @@ -33,6 +34,7 @@ export function DiffPage() { const [hideWhitespace, setHideWhitespace] = useState(false); const [showHelp, setShowHelp] = useState(false); const { theme, toggleTheme } = useTheme(initialTheme); + const { wrapLines, toggleWrapLines } = useWrapLines(); const { data: diff, error } = useDiff(hideWhitespace, refParam); const { data: info } = useInfo(refParam); const [activeFile, setActiveFile] = useState(null); @@ -334,6 +336,8 @@ export function DiffPage() { onHideWhitespaceChange={setHideWhitespace} theme={theme} onToggleTheme={toggleTheme} + wrapLines={wrapLines} + onToggleWrapLines={toggleWrapLines} onShowHelp={() => setShowHelp(true)} diff={diff || undefined} diffRef={refParam} diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index 8446ee9..4070d14 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -484,7 +484,8 @@ export function FileBlock(props: FileBlockProps) { onDeleteComment={deleteComment} onDeleteThread={deleteThread} /> - +
+
{viewMode === 'split' ? ( @@ -568,6 +569,7 @@ export function FileBlock(props: FileBlockProps) { ); })()}
+ )} diff --git a/packages/ui/src/components/diff/hunk-block-split.tsx b/packages/ui/src/components/diff/hunk-block-split.tsx index 54f7f20..1aa6f89 100644 --- a/packages/ui/src/components/diff/hunk-block-split.tsx +++ b/packages/ui/src/components/diff/hunk-block-split.tsx @@ -144,7 +144,7 @@ function SplitCell(props: { onCommentClick={onCommentClick} /> setContentHovered(true)} onMouseLeave={() => setContentHovered(false)} > diff --git a/packages/ui/src/components/icons/wrap-text-icon.tsx b/packages/ui/src/components/icons/wrap-text-icon.tsx new file mode 100644 index 0000000..dfa8162 --- /dev/null +++ b/packages/ui/src/components/icons/wrap-text-icon.tsx @@ -0,0 +1,18 @@ +export function WrapTextIcon(props: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/packages/ui/src/components/layout/options-menu.tsx b/packages/ui/src/components/layout/options-menu.tsx index c113496..89daf9c 100644 --- a/packages/ui/src/components/layout/options-menu.tsx +++ b/packages/ui/src/components/layout/options-menu.tsx @@ -3,17 +3,20 @@ import { SunIcon } from '../icons/sun-icon'; import { MoonIcon } from '../icons/moon-icon'; import { EllipsisIcon } from '../icons/ellipsis-icon'; import { GitHubIcon } from '../icons/github-icon'; +import { WrapTextIcon } from '../icons/wrap-text-icon'; export const menuItemClass = 'flex items-center gap-2.5 w-full px-3 py-1.5 text-xs text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer text-left'; interface OptionsMenuProps { theme: 'light' | 'dark'; onToggleTheme: () => void; + wrapLines: boolean; + onToggleWrapLines: () => void; renderExtraItems?: (close: () => void) => ReactNode; } export function OptionsMenu(props: OptionsMenuProps) { - const { theme, onToggleTheme, renderExtraItems } = props; + const { theme, onToggleTheme, wrapLines, onToggleWrapLines, renderExtraItems } = props; const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); @@ -44,6 +47,17 @@ export function OptionsMenu(props: OptionsMenuProps) { {showMenu && (
{renderExtraItems && renderExtraItems(close)} + +
+
+ + + {rows} + +
+
); } diff --git a/packages/ui/src/components/tree/tree-page.tsx b/packages/ui/src/components/tree/tree-page.tsx index 72ecebb..978eb81 100644 --- a/packages/ui/src/components/tree/tree-page.tsx +++ b/packages/ui/src/components/tree/tree-page.tsx @@ -18,6 +18,7 @@ import { tourOptions, } from '../../queries/tree'; import { useTheme } from '../../hooks/use-theme'; +import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useCommentActions } from '../../hooks/use-comment-actions'; import { isThreadResolved, GENERAL_THREAD_FILE_PATH } from '../comments/types'; @@ -104,6 +105,7 @@ export function TreePage(props: TreePageProps) { const { theme, toggleTheme } = useTheme( initialTheme ?? loaderData?.theme ?? null, ); + const { wrapLines, toggleWrapLines } = useWrapLines(); const queryClient = useQueryClient(); const { isStale, resetStaleness } = useTreeStaleness(); @@ -459,7 +461,12 @@ export function TreePage(props: TreePageProps) { onDeleteAllComments={commentActions.deleteAllThreads} formatForCopy={formatForCopy} /> - + diff --git a/packages/ui/src/hooks/use-wrap-lines.ts b/packages/ui/src/hooks/use-wrap-lines.ts new file mode 100644 index 0000000..b988b0b --- /dev/null +++ b/packages/ui/src/hooks/use-wrap-lines.ts @@ -0,0 +1,34 @@ +import { useState, useLayoutEffect, useCallback } from 'react'; + +const STORAGE_KEY = 'diffity-wrap-lines'; + +function getStoredWrapLines(): boolean | null { + if (typeof window === 'undefined') { + return null; + } + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === null) { + return null; + } + return stored === 'true'; +} + +export function useWrapLines() { + const [wrapLines, setWrapLines] = useState( + () => getStoredWrapLines() ?? true + ); + + useLayoutEffect(() => { + document.documentElement.setAttribute('data-wrap-lines', String(wrapLines)); + }, [wrapLines]); + + const toggleWrapLines = useCallback(() => { + setWrapLines(prev => { + const next = !prev; + localStorage.setItem(STORAGE_KEY, String(next)); + return next; + }); + }, []); + + return { wrapLines, toggleWrapLines }; +} diff --git a/packages/ui/src/styles/app.css b/packages/ui/src/styles/app.css index 7edc322..aca252b 100644 --- a/packages/ui/src/styles/app.css +++ b/packages/ui/src/styles/app.css @@ -365,6 +365,36 @@ dialog { border-left: 1px solid var(--color-border); } +/* + * Code cells wrap by default (long lines stay readable without horizontal + * scrolling). `data-wrap-lines="false"` on opts out, letting the + * surrounding .code-scroll container scroll horizontally instead. + */ +.code-cell { + white-space: pre-wrap; + word-break: break-all; +} +:root[data-wrap-lines='false'] .code-cell { + white-space: pre; + word-break: normal; +} + +.code-scroll { + overflow-x: clip; +} +:root[data-wrap-lines='false'] .code-scroll { + overflow-x: auto; +} + +/* + * Fixed layout keeps gutter/pane widths stable while wrapping. With wrapping + * off, columns must grow to the longest line so .code-scroll has something to + * scroll (and so split panes don't overlap each other). + */ +:root[data-wrap-lines='false'] .code-table { + table-layout: auto; +} + .diff-empty-cell { background: repeating-linear-gradient( From 855a35c79a9e93b2968656c8d604920abb531637 Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 10 Aug 2026 09:17:13 +0100 Subject: [PATCH 2/3] fix: pin sticky file header flush and label wrap-lines off state - the p-6 on the scrolling
insets the sticky rectangle, so top-0 left a strip of code visible above the pinned header. -top-6 cancels it. - the "Wrap lines" menu item now shows Off as well as On, instead of only labelling the enabled state. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui/src/components/layout/options-menu.tsx | 4 +++- packages/ui/src/components/tree/file-viewer.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/layout/options-menu.tsx b/packages/ui/src/components/layout/options-menu.tsx index 89daf9c..72819e9 100644 --- a/packages/ui/src/components/layout/options-menu.tsx +++ b/packages/ui/src/components/layout/options-menu.tsx @@ -56,7 +56,9 @@ export function OptionsMenu(props: OptionsMenuProps) { > Wrap lines - {wrapLines && On} + + {wrapLines ? 'On' : 'Off'} +