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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ diffity list --json # machine-readable output
--port <port> Custom port (default: auto-assigned from 5391)
--no-open Don't open browser
--dark Dark mode
--colorblind Colorblind-safe diff palette (blue/orange)
--unified Unified view (default: split)
--quiet Minimal terminal output
--new Stop existing instance and start fresh
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function registerTreeCommand(program: Command, version: string) {
.option('--port <port>', 'Port to use')
.option('--no-open', 'Do not open browser automatically')
.option('--dark', 'Open in dark mode')
.option('--colorblind', 'Use a colorblind-safe diff palette (blue/orange)')
.option('--quiet', 'Minimal terminal output')
.option('--new', 'Stop existing instance and start fresh')
.action(async (opts) => {
Expand Down Expand Up @@ -41,6 +42,9 @@ export function registerTreeCommand(program: Command, version: string) {
if (opts.dark) {
urlParams.set('theme', 'dark');
}
if (opts.colorblind) {
urlParams.set('colorblind', '1');
}
const qs = urlParams.toString();
const url = `http://${getHost()}:${existing.port}/tree${qs ? `?${qs}` : ''}`;

Expand Down Expand Up @@ -78,6 +82,9 @@ export function registerTreeCommand(program: Command, version: string) {
if (opts.dark) {
urlParams.set('theme', 'dark');
}
if (opts.colorblind) {
urlParams.set('colorblind', '1');
}
const qs = urlParams.toString();
const url = `http://${getHost()}:${actualPort}/tree${qs ? `?${qs}` : ''}`;

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ program
.option('--no-open', 'Do not open browser automatically')
.option('--quiet', 'Minimal terminal output')
.option('--dark', 'Open in dark mode (default: light)')
.option('--colorblind', 'Use a colorblind-safe diff palette (blue/orange)')
.option('--unified', 'Open in unified view (default: split)')
.option('--new', 'Stop existing instance and start fresh')
.addHelpText('after', `
Expand All @@ -58,6 +59,7 @@ Common usage:
$ diffity unstaged Only unstaged changes
$ diffity https://github.com/owner/repo/pull/123 Review a GitHub PR
$ diffity --dark --unified Dark mode, unified view
$ diffity --colorblind Colorblind-safe diff palette
$ diffity --new Force restart existing instance

Other commands:
Expand Down Expand Up @@ -91,6 +93,7 @@ range syntax (main..feature, main...feature) also work.`)
case '--no-open': opts.open = false; break;
case '--quiet': opts.quiet = true; break;
case '--dark': opts.dark = true; break;
case '--colorblind': opts.colorblind = true; break;
case '--unified': opts.unified = true; break;
case '--new': opts.new = true; break;
default:
Expand Down Expand Up @@ -248,6 +251,9 @@ range syntax (main..feature, main...feature) also work.`)
if (opts.dark) {
urlParams.set('theme', 'dark');
}
if (opts.colorblind) {
urlParams.set('colorblind', '1');
}
if (opts.unified) {
urlParams.set('view', 'unified');
}
Expand Down Expand Up @@ -286,6 +292,9 @@ range syntax (main..feature, main...feature) also work.`)
if (opts.dark) {
urlParams.set('theme', 'dark');
}
if (opts.colorblind) {
urlParams.set('colorblind', '1');
}
if (opts.unified) {
urlParams.set('view', 'unified');
}
Expand Down
8 changes: 6 additions & 2 deletions packages/ui/src/components/diff/diff-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useLoaderData } from 'react-router';
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 { useTheme, useColorblind } from '../../hooks/use-theme';
import { useKeyboard } from '../../hooks/use-keyboard';
import { useReviewThreads } from '../../hooks/use-review-threads';
import { useCommentActions } from '../../hooks/use-comment-actions';
Expand All @@ -23,16 +23,18 @@ import type { LineSelection } from '../comments/types';
import { isThreadResolved } from '../comments/types';

export function DiffPage() {
const { ref: refParam, theme: initialTheme, view: initialViewMode } = useLoaderData<{
const { ref: refParam, theme: initialTheme, view: initialViewMode, colorblind: initialColorblind } = useLoaderData<{
ref: string;
theme: 'light' | 'dark' | null;
view: 'split' | 'unified' | null;
colorblind: boolean;
}>();

const [viewMode, setViewMode] = useState<ViewMode>(initialViewMode || 'split');
const [hideWhitespace, setHideWhitespace] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const { theme, toggleTheme } = useTheme(initialTheme);
const { colorblind, toggleColorblind } = useColorblind(initialColorblind);
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}
colorblind={colorblind}
onToggleColorblind={toggleColorblind}
onShowHelp={() => setShowHelp(true)}
diff={diff || undefined}
diffRef={refParam}
Expand Down
17 changes: 16 additions & 1 deletion packages/ui/src/components/layout/options-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { useState, useRef, useEffect, type ReactNode } from 'react';
import { SunIcon } from '../icons/sun-icon';
import { MoonIcon } from '../icons/moon-icon';
import { EllipsisIcon } from '../icons/ellipsis-icon';
import { EyeIcon } from '../icons/eye-icon';
import { GitHubIcon } from '../icons/github-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;
colorblind?: boolean;
onToggleColorblind?: () => void;
renderExtraItems?: (close: () => void) => ReactNode;
}

export function OptionsMenu(props: OptionsMenuProps) {
const { theme, onToggleTheme, renderExtraItems } = props;
const { theme, onToggleTheme, colorblind, onToggleColorblind, renderExtraItems } = props;
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -54,6 +57,18 @@ export function OptionsMenu(props: OptionsMenuProps) {
{theme === 'light' ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
{theme === 'light' ? 'Dark mode' : 'Light mode'}
</button>
{onToggleColorblind && (
<button
className={menuItemClass}
onClick={() => {
onToggleColorblind();
close();
}}
>
<EyeIcon className="w-3.5 h-3.5" />
{colorblind ? 'Default colors' : 'Colorblind colors'}
</button>
)}
<div className="border-t border-border my-1" />
<a
href="https://github.com/kamranahmedse/diffity"
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;
colorblind?: boolean;
onToggleColorblind?: () => void;
onShowHelp: () => void;
diff?: ParsedDiff;
diffRef?: string;
Expand Down Expand Up @@ -119,6 +121,8 @@ export function Toolbar(props: ToolbarProps) {
onHideWhitespaceChange,
theme,
onToggleTheme,
colorblind,
onToggleColorblind,
onShowHelp,
diff,
diffRef,
Expand Down Expand Up @@ -183,6 +187,8 @@ export function Toolbar(props: ToolbarProps) {
<OptionsMenu
theme={theme}
onToggleTheme={onToggleTheme}
colorblind={colorblind}
onToggleColorblind={onToggleColorblind}
renderExtraItems={(close) => (
<>
<button
Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/components/tree/tree-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
treeEntriesOptions,
tourOptions,
} from '../../queries/tree';
import { useTheme } from '../../hooks/use-theme';
import { useTheme, useColorblind } from '../../hooks/use-theme';
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 @@ -98,9 +98,10 @@ function formatTreeThreadsForCopy(threads: CommentThread[]): string {
export function TreePage(props: TreePageProps) {
const { tourId, tourStepIndex: tourStepIndexProp, initialTheme } = props;

const loaderData = useLoaderData<{ theme?: 'light' | 'dark' | null }>();
const loaderData = useLoaderData<{ theme?: 'light' | 'dark' | null; colorblind?: boolean }>();
const [searchParams, setSearchParams] = useRouterSearchParams();
const navigate = useNavigate();
const { colorblind, toggleColorblind } = useColorblind(loaderData?.colorblind ?? null);
const { theme, toggleTheme } = useTheme(
initialTheme ?? loaderData?.theme ?? null,
);
Expand Down Expand Up @@ -459,7 +460,7 @@ export function TreePage(props: TreePageProps) {
onDeleteAllComments={commentActions.deleteAllThreads}
formatForCopy={formatForCopy}
/>
<OptionsMenu theme={theme} onToggleTheme={toggleTheme} />
<OptionsMenu theme={theme} onToggleTheme={toggleTheme} colorblind={colorblind} onToggleColorblind={toggleColorblind} />
</div>
</div>

Expand Down
35 changes: 35 additions & 0 deletions packages/ui/src/hooks/use-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,38 @@ export function useTheme(initialTheme?: Theme | null) {

return { theme, toggleTheme };
}

function getStoredColorblind(): boolean | null {
if (typeof window === 'undefined') {
return null;
}
const stored = localStorage.getItem('diffity-colorblind');
return stored === null ? null : stored === 'true';
}

// Colorblind mode is an orthogonal axis to light/dark — it swaps the diff
// palette to a colorblind-safe blue/orange scheme via the `data-colorblind`
// attribute (see styles/app.css).
export function useColorblind(initialColorblind?: boolean | null) {
const [colorblind, setColorblind] = useState<boolean>(
() => getStoredColorblind() ?? initialColorblind ?? false
);

useLayoutEffect(() => {
if (colorblind) {
document.documentElement.setAttribute('data-colorblind', 'true');
} else {
document.documentElement.removeAttribute('data-colorblind');
}
}, [colorblind]);

const toggleColorblind = useCallback(() => {
setColorblind(prev => {
const next = !prev;
localStorage.setItem('diffity-colorblind', String(next));
return next;
});
}, []);

return { colorblind, toggleColorblind };
}
3 changes: 2 additions & 1 deletion packages/ui/src/routes/diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const ref = url.searchParams.get("ref") || "work";
const theme = url.searchParams.get("theme") as "light" | "dark" | null;
const view = url.searchParams.get("view") as "split" | "unified" | null;
const colorblind = url.searchParams.get("colorblind") === "1";

await Promise.all([
queryClient.ensureQueryData(diffOptions(false, ref)),
queryClient.ensureQueryData(repoInfoOptions(ref)),
]);

return { ref, theme, view };
return { ref, theme, view, colorblind };
}

export default function DiffRoute({ loaderData }: Route.ComponentProps) {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/routes/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const path = url.searchParams.get("path") || "";
const type = (url.searchParams.get("type") || "dir") as "file" | "dir";
const theme = url.searchParams.get("theme") as "light" | "dark" | null;
const colorblind = url.searchParams.get("colorblind") === "1";

const fetches: Promise<unknown>[] = [
queryClient.ensureQueryData(treePathsOptions()),
Expand All @@ -24,7 +25,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {

await Promise.all(fetches);

return { path, type, theme };
return { path, type, theme, colorblind };
}

export default function TreeRoute() {
Expand Down
35 changes: 35 additions & 0 deletions packages/ui/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,41 @@
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5);
}

/*
* Colorblind-safe palette. Remaps the red/green diff colors to a
* blue (additions) / orange (deletions) scheme that stays distinguishable
* for deuteranopia and protanopia. Applied as an orthogonal axis, so it
* combines with both light and dark themes. The hunk header is shifted to a
* neutral gray so it does not collide with the blue used for additions.
*/
[data-colorblind='true'] {
--color-diff-add-bg: #ddf4ff;
--color-diff-add-line: #b6e3ff;
--color-diff-add-word: #80ccff;
--color-diff-del-bg: #fff1e5;
--color-diff-del-line: #ffd8b5;
--color-diff-del-word: #ffb77c66;
--color-diff-hunk-bg: #eaeef2;
--color-diff-hunk-text: #57606a;

--color-added: #0969da;
--color-deleted: #bc4c00;
}

[data-colorblind='true'][data-theme='dark'] {
--color-diff-add-bg: rgba(96, 165, 250, 0.1);
--color-diff-add-line: rgba(96, 165, 250, 0.18);
--color-diff-add-word: rgba(96, 165, 250, 0.35);
--color-diff-del-bg: rgba(236, 142, 44, 0.1);
--color-diff-del-line: rgba(236, 142, 44, 0.18);
--color-diff-del-word: rgba(236, 142, 44, 0.35);
--color-diff-hunk-bg: rgba(163, 163, 163, 0.1);
--color-diff-hunk-text: #a3a3a3;

--color-added: #60a5fa;
--color-deleted: #ec8e2c;
}

* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) var(--color-bg-secondary);
Expand Down