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
2 changes: 1 addition & 1 deletion packages/ui/src/components/diff/diff-line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function DiffLine(props: DiffLineProps) {
<td className={cn('w-5 min-w-5 px-1 text-center select-none align-top', getPrefixColor(line.type), isSelected && 'bg-diff-comment-bg')}>
{getPrefix(line.type)}
</td>
<td className={cn('px-3 whitespace-pre-wrap break-all', isSelected && 'bg-diff-comment-bg')}>
<td className={cn('px-3 code-cell', isSelected && 'bg-diff-comment-bg')}>
<span className="inline">{renderContent(line, syntaxTokens)}</span>
</td>
</tr>
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/diff/diff-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/components/diff/file-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,8 @@ export function FileBlock(props: FileBlockProps) {
onDeleteComment={deleteComment}
onDeleteThread={deleteThread}
/>
<table className="w-full border-collapse table-fixed">
<div className="code-scroll">
<table className="w-full border-collapse table-fixed code-table">
{viewMode === 'split' ? (
<colgroup>
<col className="w-12.5" />
Expand Down Expand Up @@ -568,6 +569,7 @@ export function FileBlock(props: FileBlockProps) {
);
})()}
</table>
</div>
</>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/diff/hunk-block-split.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ function SplitCell(props: {
onCommentClick={onCommentClick}
/>
<td
className={cn('px-3 whitespace-pre-wrap break-all border-r border-border-muted align-top', isSelected ? 'bg-diff-comment-bg' : contentBgClass)}
className={cn('px-3 code-cell border-r border-border-muted align-top', isSelected ? 'bg-diff-comment-bg' : contentBgClass)}
onMouseEnter={() => setContentHovered(true)}
onMouseLeave={() => setContentHovered(false)}
>
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/components/icons/wrap-text-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function WrapTextIcon(props: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M2 3.5h12" />
<path d="M2 8h9.5a2.5 2.5 0 010 5H8" />
<path d="M9.5 11l-1.75 2L9.5 15" />
<path d="M2 12.5h3" />
</svg>
);
}
18 changes: 17 additions & 1 deletion packages/ui/src/components/layout/options-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);

Expand Down Expand Up @@ -44,6 +47,19 @@ export function OptionsMenu(props: OptionsMenuProps) {
{showMenu && (
<div className="absolute right-0 top-full mt-1 w-48 py-1 bg-bg-secondary rounded-md shadow-lg ring-1 ring-border z-50">
{renderExtraItems && renderExtraItems(close)}
<button
className={menuItemClass}
onClick={() => {
onToggleWrapLines();
close();
}}
>
<WrapTextIcon className="w-3.5 h-3.5" />
Wrap lines
<span className={`ml-auto text-[10px] ${wrapLines ? 'text-accent' : 'text-text-muted'}`}>
{wrapLines ? 'On' : 'Off'}
</span>
</button>
<button
className={menuItemClass}
onClick={() => {
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/components/layout/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface ToolbarProps {
onHideWhitespaceChange: (hide: boolean) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
wrapLines: boolean;
onToggleWrapLines: () => void;
onShowHelp: () => void;
diff?: ParsedDiff;
diffRef?: string;
Expand Down Expand Up @@ -119,6 +121,8 @@ export function Toolbar(props: ToolbarProps) {
onHideWhitespaceChange,
theme,
onToggleTheme,
wrapLines,
onToggleWrapLines,
onShowHelp,
diff,
diffRef,
Expand Down Expand Up @@ -183,6 +187,8 @@ export function Toolbar(props: ToolbarProps) {
<OptionsMenu
theme={theme}
onToggleTheme={onToggleTheme}
wrapLines={wrapLines}
onToggleWrapLines={onToggleWrapLines}
renderExtraItems={(close) => (
<>
<button
Expand Down
60 changes: 52 additions & 8 deletions packages/ui/src/components/tree/file-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type { CommentActions } from '../../hooks/use-comment-actions';
import { CommentThread } from '../comments/comment-thread';
import { CommentForm } from '../comments/comment-form';
import { CommentLineNumber } from '../comments/comment-line-number';
import { CheckIcon } from '../icons/check-icon';
import { CopyIcon } from '../icons/copy-icon';
import { useCopy } from '../../hooks/use-copy';
import { cn } from '../../lib/cn';

interface TourHighlight {
Expand Down Expand Up @@ -44,6 +47,8 @@ export function FileViewer(props: FileViewerProps) {
const [pendingSelection, setPendingSelection] = useState<LineSelection | null>(null);
const { highlight, ready } = useHighlighter();
const tableRef = useRef<HTMLTableElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { copied: pathCopied, copy: copyPath } = useCopy();

const activeTourHighlight = tourHighlight && tourHighlight.filePath === filePath ? tourHighlight : null;
const isFullFileHighlight = activeTourHighlight
Expand Down Expand Up @@ -72,7 +77,9 @@ export function FileViewer(props: FileViewerProps) {
if (scrollParent) {
const rowTop = row.getBoundingClientRect().top;
const parentTop = scrollParent.getBoundingClientRect().top;
scrollParent.scrollTop = rowTop - parentTop;
// Keep the target line clear of the sticky file header.
const headerHeight = headerRef.current?.offsetHeight ?? 0;
scrollParent.scrollTop = rowTop - parentTop - headerHeight;
} else {
row.scrollIntoView({ block: 'start' });
}
Expand Down Expand Up @@ -213,7 +220,7 @@ export function FileViewer(props: FileViewerProps) {
/>
<td
className={cn(
'px-4 py-0 font-mono text-[13px] leading-6 whitespace-pre',
'px-4 py-0 font-mono text-[13px] leading-6 code-cell align-top',
highlightType === 'base' && 'bg-diff-comment-bg/40',
highlightType === 'focus' && 'bg-diff-comment-bg',
highlightType === 'selected' && 'bg-diff-comment-bg',
Expand Down Expand Up @@ -272,16 +279,53 @@ export function FileViewer(props: FileViewerProps) {
}
}

const dirPath = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
const fileName = filePath.slice(filePath.lastIndexOf('/') + 1);

return (
<div className={cn(
'border border-border rounded-lg overflow-x-auto',
'border border-border rounded-lg overflow-clip',
isFullFileHighlight && 'border-l-2 border-l-accent',
)}>
<table ref={tableRef} className="w-full border-collapse">
<tbody>
{rows}
</tbody>
</table>
{/*
-top-6 cancels the p-6 on the scrolling <main>: that padding insets the
sticky rectangle, which would otherwise leave a strip of code visible
above the pinned header.
*/}
<div
ref={headerRef}
className="sticky -top-6 z-10 flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border-b border-border text-xs shadow-sticky"
>
<span className="font-mono text-xs truncate">
{dirPath && <span className="text-text-muted">{dirPath}</span>}
<span className="text-text">{fileName}</span>
</span>
{activeTourHighlight && !isFullFileHighlight && (
<span className="shrink-0 font-mono text-[11px] text-text-muted">
:{activeTourHighlight.startLine === activeTourHighlight.endLine
? activeTourHighlight.startLine
: `${activeTourHighlight.startLine}-${activeTourHighlight.endLine}`}
</span>
)}
<button
onClick={() => copyPath(filePath)}
className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer"
title="Copy file path"
>
{pathCopied ? (
<CheckIcon className="w-3 h-3 text-added" />
) : (
<CopyIcon className="w-3 h-3" />
)}
</button>
</div>
<div className="code-scroll">
<table ref={tableRef} className="w-full border-collapse code-table">
<tbody>
{rows}
</tbody>
</table>
</div>
</div>
);
}
9 changes: 8 additions & 1 deletion packages/ui/src/components/tree/tree-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -459,7 +461,12 @@ export function TreePage(props: TreePageProps) {
onDeleteAllComments={commentActions.deleteAllThreads}
formatForCopy={formatForCopy}
/>
<OptionsMenu theme={theme} onToggleTheme={toggleTheme} />
<OptionsMenu
theme={theme}
onToggleTheme={toggleTheme}
wrapLines={wrapLines}
onToggleWrapLines={toggleWrapLines}
/>
</div>
</div>

Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/hooks/use-wrap-lines.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(
() => 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 };
}
30 changes: 30 additions & 0 deletions packages/ui/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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(
Expand Down