feat(mobile): add branch and commit comparison views

Mobile Changes only exposed working-tree edits. Share comparison loading and source pickers with desktop, with phone sheets, tablet popovers, scoped file navigation and retry states.

Keep checkout, Sync and commit controls under Changes. Compact the section and source triggers, center menu items, and preserve repeated file-open requests from chat.

Validated with UI type-check and lint, comparison and navigation tests, web/mobile asset builds, and maintainer app testing.

Co-authored-by: gaojunran <nebula2021@126.com>
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:15 +03:00
co-authored by gaojunran
parent 0b899d1153
commit fce3f174d2
19 changed files with 928 additions and 285 deletions
+1
View File
@@ -14,6 +14,7 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h
- The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it. - The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it.
- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. - Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection.
- The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text. - The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text.
- The Changes workspace has a top-level Changes / Branch / Commit selector. Checkout, Sync, staging, and commit controls appear only under Changes; Branch and Commit show a read-only file list that opens one diff at a time. Their shared source pickers use bottom sheets on phones and anchored popovers on tablets. Commit lists the latest 50 commits of the checked-out branch. Closing the workspace preserves the current detail and suspends comparison reads; changing repository or instance resets navigation, and a new file link from chat opens the working-tree diff.
## Commands ## Commands
@@ -0,0 +1,194 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { GitLogEntry, GitStatus } from '@/lib/api/types';
test('mobile comparisons drill into files, retry, resume, change source, and yield to external working diffs', async () => {
const dom = new Window({ url: 'http://localhost' });
dom.happyDOM.setWindowSize({ width: 390, height: 844 });
const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = {
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, localStorage: dom.localStorage,
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, Node: dom.Node,
customElements: dom.customElements, CSSStyleSheet: dom.CSSStyleSheet,
Event: dom.Event, CustomEvent: dom.CustomEvent, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent,
MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver,
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const commits: GitLogEntry[] = ['a', 'b'].map((letter) => ({
hash: letter.repeat(40), date: '2026-09-09T09:22:00Z', message: `Commit ${letter}`,
refs: '', body: '', author_name: 'Test Author', author_email: 'test@example.com',
filesChanged: 1, insertions: 0, deletions: 0, parents: [],
}));
const requests: URL[] = [];
const originalFetch = globalThis.fetch;
let failBranchDiff = true;
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost');
if (url.pathname === '/api/fs/home' || url.pathname === '/api/session-folders') return new Promise<Response>(() => {});
requests.push(url);
switch (url.pathname) {
case '/api/git/remotes': return Response.json([]);
case '/api/git/remote-url': return Response.json({ url: null });
case '/api/git/branch-base': return Response.json({ base: null });
case '/api/git/range-files': return Response.json({ files: [{ path: url.searchParams.get('base') === 'refs/heads/parent' ? 'parent.png' : 'branch.png', status: 'M' }] });
case '/api/git/range-diff':
return failBranchDiff
? Response.json({ error: 'Branch diff failed' }, { status: 500 })
: Response.json({ diff: 'Binary files a/branch.png and b/branch.png differ' });
case '/api/git/log': return Response.json({ all: commits, latest: commits[0], total: commits.length });
case '/api/git/commit-files': return Response.json({ files: [{ path: `commit-${url.searchParams.get('hash')?.[0]}.png`, previousPath: 'old.png', changeType: 'R', insertions: 0, deletions: 0, isBinary: true }] });
case '/api/git/commit-diff': return Response.json({ diff: 'Binary files a/old.png and b/commit.png differ' });
case '/api/git/file-diff': return Response.json({ path: 'working.png', original: '', modified: '', isBinary: true });
default: throw new Error(`Unexpected request ${url.pathname}`);
}
}, originalFetch);
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('@/lib/i18n');
const { RuntimeAPIContext } = await import('@/contexts/runtimeAPIContext');
const { createWebAPIs } = await import('../../../web/src/api/index');
const { useGitStore } = await import('@/stores/useGitStore');
const { MobileChangesPane } = await import('./MobileChangesSurface');
const apis = createWebAPIs();
const status: GitStatus = { current: 'feature', tracking: null, ahead: 0, behind: 0, files: [], isClean: true, diffStats: {} };
const seed = (directory: string, nextStatus = status) => {
useGitStore.getState().setActiveDirectory(directory);
const previous = useGitStore.getState().getDirectoryState(directory);
if (!previous) throw new Error('Missing repository state');
const now = Date.now();
const directories = new Map(useGitStore.getState().directories);
directories.set(directory, {
...previous, status: nextStatus, isGitRepo: true,
branches: { all: ['feature', 'main', 'parent', 'remotes/origin/main'], current: 'feature', branches: {}, defaultBranches: { origin: 'main' } },
log: { all: commits, latest: commits[0], total: 2 }, identity: { userName: 'Test Author', userEmail: 'test@example.com', sshCommand: null },
lastStatusFetch: now, lastBranchesFetch: now, lastLogFetch: now, lastIdentityFetch: now, lastRepoCheckAt: now,
});
useGitStore.setState({ directories });
};
seed('/repo');
let directory = '/repo';
let visible = true;
let initialDiff: { path: string; staged: boolean } | null = null;
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const render = () => act(async () => {
root.render(<I18nProvider><RuntimeAPIContext.Provider value={apis}>
<MobileChangesPane rootDirectory={directory}
repository={{ rootIsGitRepo: true, gitDirectory: directory, nestedRepos: null, nestedRepoSelection: null }}
visible={visible} initialDiff={initialDiff} />
</RuntimeAPIContext.Provider></I18nProvider>);
});
const click = async (selector: string) => {
const element = document.querySelector<HTMLElement>(selector);
if (!element) throw new Error(`Missing ${selector}`);
await act(async () => { element.click(); });
};
const chooseMode = async (label: string) => {
await click('[aria-label="Select change mode"]');
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((element) => element.textContent === label);
if (!option) throw new Error(`Missing mode ${label}`);
await act(async () => { option.click(); });
};
const openFile = async (path: string) => {
const button = container.querySelector(`[title="${path}"]`)?.closest('button');
if (!button) throw new Error(`Missing file ${path}`);
await act(async () => { button.click(); });
};
const comparisonRequests = () => requests.filter((url) => /\/(range-files|range-diff|commit-files|commit-diff)$/.test(url.pathname));
const checkoutControl = () => [...container.querySelectorAll('button')].find((button) => button.textContent?.trim() === 'feature');
try {
await render();
const modeTrigger = container.querySelector('[aria-label="Select change mode"]');
const syncButton = container.querySelector('[aria-label="Sync Changes"]');
if (!modeTrigger || !syncButton) throw new Error('Missing Changes controls');
expect(modeTrigger?.textContent).toBe('Changes');
expect(container.querySelector('h2')).toBeNull();
expect(checkoutControl()).toBeDefined();
expect(syncButton).not.toBeNull();
expect(modeTrigger?.closest('header')?.contains(syncButton)).toBe(false);
expect(modeTrigger && syncButton && (modeTrigger.compareDocumentPosition(syncButton) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
await chooseMode('Branch');
expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull();
expect(checkoutControl()).toBeUndefined();
expect(container.textContent).toContain('Select a base branch');
await click('[aria-label="Base branch"]');
await click('[data-value="refs/heads/main"]');
expect(container.querySelector('[title="branch.png"]')).not.toBeNull();
await openFile('branch.png');
expect(container.textContent).toContain('Branch diff failed');
expect(requests.filter((url) => url.pathname === '/api/git/file-diff')).toHaveLength(0);
failBranchDiff = false;
const retry = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Retry');
if (!retry) throw new Error('Missing diff retry');
await act(async () => { retry.click(); });
expect(container.textContent).toContain('Content of this file cannot be viewed.');
expect(container.textContent).toContain('Branch · main');
const beforeHide = comparisonRequests().length;
visible = false;
await render();
await act(async () => { seed('/repo'); });
expect(comparisonRequests()).toHaveLength(beforeHide);
visible = true;
await render();
expect(container.querySelector('h2')?.textContent).toBe('branch.png');
await click('[aria-label="Back"]');
await click('[aria-label="Base branch"]');
await click('[data-value="refs/heads/parent"]');
expect(container.querySelector('[title="branch.png"]')).toBeNull();
expect(container.querySelector('[title="parent.png"]')).not.toBeNull();
await chooseMode('Commit');
expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull();
expect(checkoutControl()).toBeUndefined();
expect(container.querySelector('[title="commit-a.png"]')).not.toBeNull();
await click('[aria-label="Select commit"]');
await click(`[data-value="${'b'.repeat(40)}"]`);
expect(container.querySelector('[title="commit-a.png"]')).toBeNull();
await openFile('commit-b.png');
expect(container.textContent).toContain('Commit · bbbbbbbb');
const commitRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/commit-diff');
expect(commitRequest?.searchParams.get('hash')).toBe('b'.repeat(40));
expect(commitRequest?.searchParams.get('previousPath')).toBe('old.png');
expect(requests.filter((url) => url.pathname === '/api/git/range-diff').every((url) => url.searchParams.get('includeWorkingTree') === 'true')).toBe(true);
await act(async () => { seed('/repo', { ...status, isClean: false, files: [{ path: 'working.png', index: 'M', working_dir: ' ' }] }); });
initialDiff = { path: 'working.png', staged: true };
await render();
expect(container.querySelector('h2')?.textContent).toBe('working.png');
const workingRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/file-diff');
expect(workingRequest?.searchParams.get('staged')).toBe('true');
await click('[aria-label="Back"]');
expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes');
expect(container.querySelector('[aria-label="Sync Changes"]')).not.toBeNull();
expect(checkoutControl()).toBeDefined();
await chooseMode('Branch');
initialDiff = { path: 'working.png', staged: true };
await render();
expect(container.querySelector('h2')?.textContent).toBe('working.png');
await act(async () => { seed('/repo-two'); });
directory = '/repo-two';
await render();
expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes');
expect(container.querySelector('h2')).toBeNull();
expect(requests.some((url) => url.pathname === '/api/git/file-diff' && url.searchParams.get('directory') === '/repo-two')).toBe(false);
} finally {
await act(async () => root.unmount());
globalThis.fetch = originalFetch;
await dom.happyDOM.abort();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
+268 -36
View File
@@ -3,9 +3,15 @@ import { Icon } from '@/components/icon/Icon';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel'; import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
import { BranchSelector } from '@/components/views/git/BranchSelector'; import { BranchSelector } from '@/components/views/git/BranchSelector';
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { branchRefLabel } from '@/components/views/git/baseBranch';
import { isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache } from '@/components/views/branchDiffScope';
import { CommitSection } from '@/components/views/git/CommitSection'; import { CommitSection } from '@/components/views/git/CommitSection';
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog'; import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
import { SyncActions } from '@/components/views/git/SyncActions'; import { SyncActions } from '@/components/views/git/SyncActions';
@@ -13,6 +19,13 @@ import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { useCommitComparison } from '@/hooks/useCommitComparison';
import { useGitComparison, type GitComparisonFile, type GitComparisonSource } from '@/hooks/useGitComparison';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { fileDiffFromPatch, isBinaryPatch } from '@/lib/diff/patchFileDiff';
import type { FileDiffMetadata } from '@pierre/diffs';
import type { GitStatus } from '@/lib/api/types'; import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi'; import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
@@ -32,6 +45,29 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null; type CommitAction = 'commit' | 'commitAndPush' | null;
type ChangesMode = 'working' | 'branch' | 'commit';
type ChangesRoute =
| { type: 'list' }
| { type: 'diff'; path: string; staged: boolean }
| { type: 'comparison'; path: string; sourceKey: string };
interface ChangesNavigation {
ownerKey: string;
mode: ChangesMode;
route: ChangesRoute;
}
interface MobileDiffData {
original: string;
modified: string;
isBinary?: boolean;
fileDiff?: FileDiffMetadata;
}
type ComparisonDiff =
| { status: 'loading' }
| { status: 'ready'; diff: MobileDiffData }
| { status: 'error'; message: string };
const LOADING_COMPARISON_DIFF: ComparisonDiff = { status: 'loading' };
const LIST_ROUTE: ChangesRoute = { type: 'list' };
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => { const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
@@ -51,21 +87,32 @@ type MobileChangesSurfaceProps = {
/** When provided, the list header gets a close X that calls this. */ /** When provided, the list header gets a close X that calls this. */
onClose?: () => void; onClose?: () => void;
/** /**
* When set (and non-null), the surface opens directly into the per-file diff view for this * A new request object opens its working-tree diff, including repeated requests
* relative path. Updating it (incl. setting it to a different path while open) routes the * for the same path. Reopening the drawer keeps the same object and navigation.
* surface to that diff. Setting it back to null leaves the user on the current internal route.
*/ */
initialDiffPath?: string | null; initialDiff?: { path: string; staged: boolean } | null;
initialDiffStaged?: boolean; /** The workspace drawer keeps visited panes mounted while hidden. */
visible?: boolean;
}; };
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => { export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = (props) => {
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
const repository = useNestedGitDirectory(rootDirectory || null, { enabled: props.visible ?? true });
return <MobileChangesPane {...props} rootDirectory={rootDirectory} repository={repository} />;
};
interface MobileChangesPaneProps extends MobileChangesSurfaceProps {
rootDirectory: string;
repository: ReturnType<typeof useNestedGitDirectory>;
}
/** Repository-scoped navigation and actions, separate from session directory resolution. */
export const MobileChangesPane: React.FC<MobileChangesPaneProps> = ({ rootDirectory, repository, onClose, initialDiff, visible = true }) => {
const { t } = useI18n(); const { t } = useI18n();
const { git } = useRuntimeAPIs(); const { git } = useRuntimeAPIs();
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
// When the root is not itself a repository, changes come from the resolved // When the root is not itself a repository, changes come from the resolved
// nested repository instead. // nested repository instead.
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); const { rootIsGitRepo, gitDirectory, nestedRepos } = repository;
const currentDirectory = gitDirectory ?? rootDirectory; const currentDirectory = gitDirectory ?? rootDirectory;
const status = useGitStatus(currentDirectory || null); const status = useGitStatus(currentDirectory || null);
const branches = useGitBranches(currentDirectory || null); const branches = useGitBranches(currentDirectory || null);
@@ -81,21 +128,41 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const getDiff = useGitStore((state) => state.getDiff); const getDiff = useGitStore((state) => state.getDiff);
const setDiff = useGitStore((state) => state.setDiff); const setDiff = useGitStore((state) => state.setDiff);
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>( const runtimeKey = useGitStore((state) => state.runtimeKey);
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }), const ownerKey = JSON.stringify([runtimeKey, currentDirectory]);
); const ownerKeyRef = React.useRef(ownerKey);
ownerKeyRef.current = ownerKey;
const [navigation, setNavigation] = React.useState<ChangesNavigation>(() => ({
ownerKey,
mode: 'working',
route: initialDiff?.path ? { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } : LIST_ROUTE,
}));
const mode = navigation.ownerKey === ownerKey ? navigation.mode : 'working';
const route = navigation.ownerKey === ownerKey ? navigation.route : LIST_ROUTE;
const [modeMenuOpen, setModeMenuOpen] = React.useState(false);
const changeMode = React.useCallback((nextMode: ChangesMode) => {
setNavigation({ ownerKey, mode: nextMode, route: LIST_ROUTE });
setModeMenuOpen(false);
}, [ownerKey]);
const setRoute = React.useCallback((nextRoute: ChangesRoute) => {
setNavigation((current) => ({ ownerKey, mode: current.ownerKey === ownerKey ? current.mode : 'working', route: nextRoute }));
}, [ownerKey]);
React.useEffect(() => {
setNavigation((current) => current.ownerKey === ownerKey ? current : { ownerKey, mode: 'working', route: LIST_ROUTE });
setModeMenuOpen(false);
}, [ownerKey]);
React.useEffect(() => { if (!visible) setModeMenuOpen(false); }, [visible]);
// Allow the host (MobileApp) to push us into a specific diff when the surface // Allow the host (MobileApp) to push us into a specific diff when the surface
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests // is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
// a different file mid-session. // a different file mid-session.
React.useEffect(() => { React.useEffect(() => {
if (!initialDiffPath) return; if (!initialDiff?.path) return;
setRoute((current) => ( // A new external target is a working-tree diff, regardless of the last
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged // comparison mode. Changing directories alone must not replay this target.
? current setNavigation({ ownerKey: ownerKeyRef.current, mode: 'working', route: { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } });
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } }, [initialDiff]);
));
}, [initialDiffPath, initialDiffStaged]);
const [syncAction, setSyncAction] = React.useState<SyncAction>(null); const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null); const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [commitMessage, setCommitMessage] = React.useState(''); const [commitMessage, setCommitMessage] = React.useState('');
@@ -110,6 +177,54 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null); const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const currentBranch = status?.current ?? null;
const trackingRemote = status?.tracking?.trim().split('/')[0];
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) ?? branches?.defaultBranches?.origin ?? null;
const showBranchOption = isBranchScopeAvailable(currentBranch, defaultBranch);
const branchUnavailable = isGitRepo === false || isBranchScopeDefinitelyUnavailable(currentBranch, defaultBranch, status !== null, branches !== null);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
const branchComparison = useBranchComparisonBase(currentDirectory || null, currentBranch, visible && mode === 'branch' && showBranchOption);
const commitComparison = useCommitComparison(currentDirectory || null, currentBranch, visible && mode === 'commit' && isGitRepo === true);
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null;
const comparisonSource = React.useMemo<GitComparisonSource | null>(() => {
if (mode === 'branch' && currentBranch && branchComparison.base) return { kind: 'branch', baseRef: branchComparison.base, headRef: currentBranch };
if (mode === 'commit' && selectedCommitHash) return { kind: 'commit', hash: selectedCommitHash };
return null;
}, [branchComparison.base, currentBranch, mode, selectedCommitHash]);
const comparisonRevision = mode === 'branch' ? branchComparison.revision : '';
const comparison = useGitComparison(currentDirectory || null, comparisonSource, visible && isGitRepo === true, comparisonRevision);
const { fetchDiff: loadComparisonDiff } = comparison;
const comparisonFiles = React.useMemo(() => comparison.files ? [...comparison.files].sort((a, b) => a.path.localeCompare(b.path)) : null, [comparison.files]);
const activeComparisonPath = route.type === 'comparison' && route.sourceKey === comparison.key ? route.path : null;
const [comparisonRetry, setComparisonRetry] = React.useState(0);
const fetchComparisonDiff = React.useCallback(async (path: string): Promise<ComparisonDiff> => {
try {
const { diff: patch } = await loadComparisonDiff(path);
const diff: MobileDiffData = { original: '', modified: '', isBinary: isBinaryPatch(patch) };
if (!diff.isBinary) diff.fileDiff = fileDiffFromPatch(path, patch);
return { status: 'ready', diff };
} catch (error) {
return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') };
}
}, [loadComparisonDiff, t]);
const comparisonDiffs = useRangeKeyedCache<ComparisonDiff>(
comparison.files ? comparison.key : null,
visible && activeComparisonPath ? activeComparisonPath : '',
visible ? fetchComparisonDiff : null,
LOADING_COMPARISON_DIFF,
JSON.stringify([comparisonRevision, comparisonRetry]),
);
const activeComparisonDiff = activeComparisonPath ? comparisonDiffs.get(activeComparisonPath) ?? LOADING_COMPARISON_DIFF : null;
React.useEffect(() => {
if (mode === 'branch' && branchUnavailable) changeMode('working');
}, [branchUnavailable, changeMode, mode]);
React.useEffect(() => {
setNavigation((current) => current.ownerKey === ownerKey && current.route.type === 'comparison' && current.route.sourceKey !== comparison.key
? { ...current, route: LIST_ROUTE }
: current);
}, [comparison.key, ownerKey]);
const changeEntries = React.useMemo(() => { const changeEntries = React.useMemo(() => {
const files = status?.files ?? []; const files = status?.files ?? [];
const unique = new Map<string, (typeof files)[number]>(); const unique = new Map<string, (typeof files)[number]>();
@@ -199,7 +314,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => { const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
if (!currentDirectory) return; if (!currentDirectory) return;
try { try {
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD'); await git.createBranch(currentDirectory, branch, currentBranch ?? 'HEAD');
await git.checkoutBranch(currentDirectory, branch); await git.checkoutBranch(currentDirectory, branch);
if (remote) { if (remote) {
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] }); await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
@@ -209,7 +324,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed')); toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
throw error; throw error;
} }
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]); }, [currentBranch, currentDirectory, git, refreshStatusAndBranches, t]);
const refreshRemotes = React.useCallback(async () => { const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory) { if (!currentDirectory) {
@@ -231,17 +346,17 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
}, [currentDirectory, git]); }, [currentDirectory, git]);
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory) return; if (!currentDirectory || !visible) return;
setActiveDirectory(currentDirectory); setActiveDirectory(currentDirectory);
void ensureAll(currentDirectory, git); void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git, setActiveDirectory]); }, [currentDirectory, ensureAll, git, setActiveDirectory, visible]);
React.useEffect(() => { React.useEffect(() => {
void refreshRemotes(); if (visible) void refreshRemotes();
}, [refreshRemotes]); }, [refreshRemotes, visible]);
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || changeEntries.length === 0) return; if (!visible || mode !== 'working' || !currentDirectory || changeEntries.length === 0) return;
const orderedPaths = Array.from(new Set([ const orderedPaths = Array.from(new Set([
...stagedChangeEntries.map((entry) => entry.path), ...stagedChangeEntries.map((entry) => entry.path),
...visibleChangePaths, ...visibleChangePaths,
@@ -252,9 +367,10 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 }); void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
}, 120); }, 120);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); }, [changeEntries, currentDirectory, git, mode, prefetchDiffs, stagedChangeEntries, visibleChangePaths, visible]);
React.useEffect(() => { React.useEffect(() => {
if (!visible) return;
if (route.type !== 'diff') { if (route.type !== 'diff') {
setDiffLoadError(null); setDiffLoadError(null);
return; return;
@@ -285,7 +401,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]); }, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff, visible]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => { const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return; if (!currentDirectory) return;
@@ -349,7 +465,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => { const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
setRoute({ type: 'diff', path, staged }); setRoute({ type: 'diff', path, staged });
}, []); }, [setRoute]);
const handleRevertFile = React.useCallback(async (filePath: string) => { const handleRevertFile = React.useCallback(async (filePath: string) => {
if (!currentDirectory) return; if (!currentDirectory) return;
@@ -580,9 +696,62 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
); );
} }
const modeLabel = mode === 'branch' ? t('diffView.scope.branch') : mode === 'commit' ? t('commitComparison.mode') : t('mobile.nav.changes');
const sourceLabel = mode === 'branch' && branchComparison.base
? branchRefLabel(branchComparison.base)
: mode === 'commit' ? selectedCommitHash?.slice(0, 8) : null;
if (activeComparisonPath && activeComparisonDiff) {
return (
<MobileDiffDetail
path={activeComparisonPath}
subtitle={[modeLabel, sourceLabel].filter(Boolean).join(' · ')}
diff={activeComparisonDiff.status === 'ready' ? activeComparisonDiff.diff : null}
fileExists={!comparison.files || comparison.files.some((file) => file.path === activeComparisonPath)}
error={comparison.error ?? (activeComparisonDiff.status === 'error' ? activeComparisonDiff.message : null)}
onBack={() => setRoute(LIST_ROUTE)}
onRetry={() => {
if (comparison.error) void comparison.refresh();
setComparisonRetry((value) => value + 1);
}}
/>
);
}
const renderComparison = () => {
if (mode === 'branch' && !branchComparison.base) {
return <MobileChangesState
loading={!branchComparison.resolved}
message={branchComparison.resolved ? t('gitView.pr.toast.baseBranchRequired') : t('diffView.branch.resolvingBase')}
/>;
}
if (mode === 'commit' && !selectedCommitHash) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
{commitComparison.loading && <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />}
<p className="typography-ui-label text-muted-foreground">{commitComparison.loading ? t('diffView.state.loadingChanges') : commitComparison.error ?? t('commitComparison.noCommits')}</p>
{commitComparison.error && <Button variant="outline" size="lg" onClick={() => void commitComparison.refresh()}>{t('diffView.actions.retry')}</Button>}
</div>;
}
if (comparison.error) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="typography-ui-label font-semibold">{t('diffView.state.failedToLoadDiff')}</p>
<p className="typography-meta text-muted-foreground">{comparison.error}</p>
<Button variant="outline" size="lg" onClick={() => void comparison.refresh()}>{t('diffView.actions.retry')}</Button>
</div>;
}
if (!comparisonFiles) return <MobileChangesState loading message={t('diffView.state.loadingChanges')} />;
if (comparisonFiles.length === 0) {
return <MobileChangesState icon={mode === 'branch'} message={mode === 'commit'
? t('commitComparison.emptyDiff')
: t('diffView.branch.empty', { base: sourceLabel ?? '' })} />;
}
return <MobileComparisonFileList files={comparisonFiles} onSelect={(path) => {
if (comparison.key) setRoute({ type: 'comparison', path, sourceKey: comparison.key });
}} />;
};
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground"> <div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground"> <header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{onClose ? ( {onClose ? (
<button <button
type="button" type="button"
@@ -594,8 +763,38 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
<Icon name="close" className="size-5" /> <Icon name="close" className="size-5" />
</button> </button>
) : null} ) : null}
<div className="min-w-0 flex-1 px-1"> <DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2> <DropdownMenuTrigger asChild>
<Button variant="outline" className={dropdownTriggerVariants({ size: 'default' })} data-mobile-comparison-trigger aria-label={t('diffView.scope.selectorAria')}>
<span>{modeLabel}</span>
<Icon name="arrow-down-s" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup value={mode} onValueChange={(value) => {
if (value === 'working' || value === 'branch' || value === 'commit') changeMode(value);
}}>
<DropdownMenuRadioItem value="working" className="min-h-8 items-center">{t('mobile.nav.changes')}</DropdownMenuRadioItem>
{showBranchOption && <DropdownMenuRadioItem value="branch" className="min-h-8 items-center">{t('diffView.scope.branch')}</DropdownMenuRadioItem>}
<DropdownMenuRadioItem value="commit" className="min-h-8 items-center">{t('commitComparison.mode')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{visible && mode === 'branch' && (
<BranchComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
branches={branches?.all ?? []} currentBranch={currentBranch} base={branchComparison.base}
onSelect={(base) => { if (currentBranch) setBaseOverride(currentDirectory, currentBranch, base); }} />
)}
{visible && mode === 'commit' && (
<CommitComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
commits={commitComparison.commits} selectedHash={selectedCommitHash}
loading={commitComparison.loading} error={commitComparison.error}
onSelect={commitComparison.select} onRefresh={() => void commitComparison.refresh()} />
)}
</header>
{mode === 'working' && (
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<BranchSelector <BranchSelector
currentBranch={status?.current} currentBranch={status?.current}
localBranches={localBranches} localBranches={localBranches}
@@ -621,8 +820,11 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
trackingRemoteName={status?.tracking?.split('/')[0]} trackingRemoteName={status?.tracking?.split('/')[0]}
hasUncommittedChanges={changeEntries.length > 0} hasUncommittedChanges={changeEntries.length > 0}
/> />
</header> </div>
{changeEntries.length > 0 ? ( )}
{mode !== 'working' ? (
<div className="min-h-0 flex-1">{renderComparison()}</div>
) : changeEntries.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */} {/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */}
<div className="min-h-0 flex-1 overflow-hidden px-4 pt-4"> <div className="min-h-0 flex-1 overflow-hidden px-4 pt-4">
@@ -735,12 +937,13 @@ const MobileChangesState: React.FC<{
const MobileDiffDetail: React.FC<{ const MobileDiffDetail: React.FC<{
path: string; path: string;
diff: { original: string; modified: string; isBinary?: boolean } | null; subtitle?: string;
diff: MobileDiffData | null;
fileExists: boolean; fileExists: boolean;
error: string | null; error: string | null;
onBack: () => void; onBack: () => void;
onRetry: () => void; onRetry: () => void;
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => { }> = ({ path, subtitle, diff, fileExists, error, onBack, onRetry }) => {
const { t } = useI18n(); const { t } = useI18n();
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]); const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
@@ -757,6 +960,7 @@ const MobileDiffDetail: React.FC<{
</button> </button>
<div className="min-w-0 flex-1 px-2"> <div className="min-w-0 flex-1 px-2">
<h2 className="truncate typography-ui-header text-foreground">{path}</h2> <h2 className="truncate typography-ui-header text-foreground">{path}</h2>
{subtitle && <p className="truncate typography-meta text-muted-foreground">{subtitle}</p>}
</div> </div>
</header> </header>
<div className="min-h-0 flex-1 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
@@ -774,7 +978,7 @@ const MobileDiffDetail: React.FC<{
<MobileChangesState loading message={t('diffView.state.loadingDiff')} /> <MobileChangesState loading message={t('diffView.state.loadingDiff')} />
) : diff.isBinary ? ( ) : diff.isBinary ? (
<MobileChangesState icon message={t('diffView.binary.unavailable')} /> <MobileChangesState icon message={t('diffView.binary.unavailable')} />
) : isImageFile(path) ? ( ) : isImageFile(path) && !diff.fileDiff ? (
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} /> <MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : ( ) : (
<ScrollShadow <ScrollShadow
@@ -785,6 +989,7 @@ const MobileDiffDetail: React.FC<{
<PierreDiffViewer <PierreDiffViewer
original={diff.original} original={diff.original}
modified={diff.modified} modified={diff.modified}
fileDiff={diff.fileDiff}
language={language} language={language}
fileName={path} fileName={path}
renderSideBySide={false} renderSideBySide={false}
@@ -797,3 +1002,30 @@ const MobileDiffDetail: React.FC<{
</div> </div>
); );
}; };
const MobileComparisonFileList: React.FC<{ files: GitComparisonFile[]; onSelect: (path: string) => void }> = ({ files, onSelect }) => {
const { t } = useI18n();
return (
<ScrollShadow className="h-full overflow-y-auto overflow-x-hidden px-3 py-2">
<ul aria-label={t('gitView.changes.changedFilesAria')} className="flex flex-col gap-1">
{files.map((file) => {
const statusLabel = file.status === 'A' ? t('diffView.change.new')
: file.status === 'D' ? t('diffView.change.deleted')
: file.status === 'R' ? t('diffView.change.renamed')
: file.status === 'C' ? t('diffView.change.copied') : t('diffView.change.modified');
const statusColor = file.status === 'A' ? 'var(--status-success)' : file.status === 'D' ? 'var(--status-error)'
: file.status === 'R' || file.status === 'C' ? 'var(--status-info)' : 'var(--status-warning)';
return <li key={file.path}>
<Button variant="ghost" size="lg" className="w-full justify-start gap-2.5 text-left normal-case" onClick={() => onSelect(file.path)}>
<span className="w-4 shrink-0 text-center typography-meta font-semibold uppercase" style={{ color: statusColor }} aria-label={statusLabel}>{file.status}</span>
<FileTypeIcon filePath={file.path} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label" title={file.path}>{file.path}</span>
{(file.insertions > 0 || file.deletions > 0) && <span className="shrink-0 typography-meta text-muted-foreground">+{file.insertions} -{file.deletions}</span>}
<Icon name="arrow-right-s" className="size-4 shrink-0 text-muted-foreground" />
</Button>
</li>;
})}
</ul>
</ScrollShadow>
);
};
@@ -236,14 +236,14 @@ export const MobileWorkspaceDrawer: React.FC<{
{visitedTabs.has('changes') ? ( {visitedTabs.has('changes') ? (
<div <div
// A newly requested per-file diff remounts the pane so // A newly requested per-file diff remounts the pane so
// initialDiffPath applies; plain reopens keep the state. // initialDiff applies; plain reopens keep the state.
key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'} key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'}
className={cn('h-full', tab !== 'changes' && 'hidden')} className={cn('h-full', tab !== 'changes' && 'hidden')}
> >
<ErrorBoundary> <ErrorBoundary>
<MobileChangesSurface <MobileChangesSurface
initialDiffPath={pendingChangesDiff?.path ?? null} visible={open && tab === 'changes'}
initialDiffStaged={pendingChangesDiff?.staged === true} initialDiff={pendingChangesDiff}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -117,6 +117,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
entered ? 'opacity-100' : 'opacity-0', entered ? 'opacity-100' : 'opacity-0',
)} )}
role="dialog" role="dialog"
aria-label={title}
aria-modal="true" aria-modal="true"
onClick={onClose} onClick={onClose}
// The panel centers over the CHAT column, not the whole app: on a tablet // The panel centers over the CHAT column, not the whole app: on a tablet
@@ -12,6 +12,7 @@ import { cva } from 'class-variance-authority';
* Sizes: * Sizes:
* - `sm` — dense surfaces: chat composer, toolbars, list rows (h-6). * - `sm` — dense surfaces: chat composer, toolbars, list rows (h-6).
* - `default` — forms, dialogs, and settings pages (h-8). * - `default` — forms, dialogs, and settings pages (h-8).
* - `touch` — mobile value pickers (h-11).
*/ */
export const dropdownTriggerVariants = cva( export const dropdownTriggerVariants = cva(
[ [
@@ -27,6 +28,7 @@ export const dropdownTriggerVariants = cva(
size: { size: {
sm: "h-6 min-h-6 px-2 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-6 min-h-6 px-2 [&_svg:not([class*='size-'])]:size-3.5",
default: "h-8 min-h-8 px-3 [&_svg:not([class*='size-'])]:size-4", default: "h-8 min-h-8 px-3 [&_svg:not([class*='size-'])]:size-4",
touch: "h-11 min-h-11 px-3 [&_svg:not([class*='size-'])]:size-4",
}, },
}, },
defaultVariants: { defaultVariants: {
+22 -91
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore'; import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore';
import { useCommitComparison } from '@/hooks/useCommitComparison'; import { useCommitComparison } from '@/hooks/useCommitComparison';
import { useGitComparison, type GitComparisonSource } from '@/hooks/useGitComparison';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector'; import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
@@ -11,11 +12,10 @@ import { branchRefLabel } from '@/components/views/git/baseBranch';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore'; import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase'; import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; import { coerceDiffScope, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
import { getGitRangeDiff, getGitRangeFiles, getCommitFiles, getGitCommitDiff } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GitStatus, GitRangeFileEntry, CommitFileEntry } from '@/lib/api/types'; import type { GitStatus } from '@/lib/api/types';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -47,7 +47,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor'; import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store'; import type { I18nKey } from '@/lib/i18n/store';
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; import { fileDiffFromPatch, isBinaryPatch } from '@/lib/diff/patchFileDiff';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { startReviewFlow } from '@/lib/reviewFlow'; import { startReviewFlow } from '@/lib/reviewFlow';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
@@ -196,9 +196,6 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number
return 1; return 1;
}; };
const isBinaryPatch = (patch: string): boolean =>
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => { const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value.filter((diff): diff is TurnSnapshotDiff => { return value.filter((diff): diff is TurnSnapshotDiff => {
@@ -1163,29 +1160,6 @@ export const DiffView: React.FC<DiffViewProps> = ({
const currentBranch = status?.current ?? null; const currentBranch = status?.current ?? null;
const commitComparison = useCommitComparison(effectiveDirectory ?? null, currentBranch, activeDiffScope === 'commit' && !isVSCodeRuntime()); const commitComparison = useCommitComparison(effectiveDirectory ?? null, currentBranch, activeDiffScope === 'commit' && !isVSCodeRuntime());
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null; const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null;
const commitQueryKey = activeDiffScope === 'commit' && effectiveDirectory && selectedCommitHash
? JSON.stringify([getRuntimeKey(), effectiveDirectory, selectedCommitHash])
: null;
const [commitFilesResult, setCommitFilesResult] = React.useState<
{ key: string; files: CommitFileEntry[] } | { key: string; error: string } | null
>(null);
const [commitFilesRetry, setCommitFilesRetry] = React.useState(0);
const currentCommitFiles = commitFilesResult?.key === commitQueryKey ? commitFilesResult : null;
const commitFiles = currentCommitFiles && 'files' in currentCommitFiles ? currentCommitFiles.files : null;
const commitFilesError = currentCommitFiles && 'error' in currentCommitFiles ? currentCommitFiles.error : null;
const commitFilesByPath = React.useMemo(() => new Map((commitFiles ?? []).map((file) => [file.path, file])), [commitFiles]);
React.useEffect(() => {
if (!commitQueryKey || !effectiveDirectory || !selectedCommitHash) return;
let cancelled = false;
setCommitFilesResult(null);
getCommitFiles(effectiveDirectory, selectedCommitHash)
.then(({ files }) => { if (!cancelled) setCommitFilesResult({ key: commitQueryKey, files }); })
.catch((error) => {
if (!cancelled) setCommitFilesResult({ key: commitQueryKey, error: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') });
});
return () => { cancelled = true; };
}, [commitFilesRetry, commitQueryKey, effectiveDirectory, selectedCommitHash, t]);
React.useEffect(() => { React.useEffect(() => {
if (activeDiffScope === 'commit' && isVSCodeRuntime()) { if (activeDiffScope === 'commit' && isVSCodeRuntime()) {
setActiveDiffScope('working'); setActiveDiffScope('working');
@@ -1271,50 +1245,23 @@ export const DiffView: React.FC<DiffViewProps> = ({
} }
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]); }, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
const branchQueryKey = effectiveDirectory && currentBranch && branchBase const comparisonSource = React.useMemo<GitComparisonSource | null>(() => {
? JSON.stringify([getRuntimeKey(), effectiveDirectory, branchBase, currentBranch]) if (activeDiffScope === 'commit' && selectedCommitHash) return { kind: 'commit', hash: selectedCommitHash };
: null; if (activeDiffScope === 'branch' && branchBase && currentBranch) return { kind: 'branch', baseRef: branchBase, headRef: currentBranch };
const [branchFilesResult, setBranchFilesResult] = React.useState< return null;
{ key: string; files: GitRangeFileEntry[] } | { key: string; error: string } | null }, [activeDiffScope, branchBase, currentBranch, selectedCommitHash]);
>(null); const comparison = useGitComparison(effectiveDirectory ?? null, comparisonSource, !isVSCodeRuntime(), activeDiffScope === 'branch' ? branchRevision : '');
const currentBranchFilesResult = branchFilesResult?.key === branchQueryKey ? branchFilesResult : null; const { fetchDiff: loadComparisonDiff } = comparison;
const branchFiles = currentBranchFilesResult && 'files' in currentBranchFilesResult ? currentBranchFilesResult.files : null; const commitFiles = activeDiffScope === 'commit' ? comparison.files : null;
const branchFilesError = currentBranchFilesResult && 'error' in currentBranchFilesResult ? currentBranchFilesResult.error : null; const commitFilesError = activeDiffScope === 'commit' ? comparison.error : null;
const branchFiles = activeDiffScope === 'branch' ? comparison.files : null;
// Shared by the scope/base effect and the error-state Retry button; the const branchFilesError = activeDiffScope === 'branch' ? comparison.error : null;
// fetch id discards completions from a superseded run (base or head
// changed, or an earlier retry is still in flight).
const branchFilesFetchIdRef = React.useRef(0);
const reloadBranchFiles = React.useCallback(() => {
if (!effectiveDirectory || !currentBranch || !branchBase || !branchQueryKey) return;
const fetchId = branchFilesFetchIdRef.current + 1;
branchFilesFetchIdRef.current = fetchId;
setBranchFilesResult((previous) => previous?.key === branchQueryKey && 'files' in previous ? previous : null);
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch, includeWorkingTree: true })
.then((files) => {
if (branchFilesFetchIdRef.current === fetchId) setBranchFilesResult({ key: branchQueryKey, files });
})
.catch((error) => {
if (branchFilesFetchIdRef.current === fetchId) {
setBranchFilesResult({ key: branchQueryKey, error: error instanceof Error ? error.message : t('diffView.branch.loadError') });
}
});
}, [branchBase, branchQueryKey, currentBranch, effectiveDirectory, t]);
React.useEffect(() => {
if (activeDiffScope === 'branch') {
reloadBranchFiles();
}
return () => { branchFilesFetchIdRef.current += 1; };
}, [activeDiffScope, branchRevision, reloadBranchFiles]);
// Range diffs are fetched per expanded file: unlike working/staged diffs // Range diffs are fetched per expanded file: unlike working/staged diffs
// there is no per-file cache channel, so patch data lives in a range-keyed // there is no per-file cache channel, so patch data lives in a range-keyed
// local cache. Stale completions from a previous range cannot write into // local cache. Stale completions from a previous range cannot write into
// the new range's cache (see useRangeKeyedCache). // the new range's cache (see useRangeKeyedCache).
const comparisonRangeKey = activeDiffScope === 'commit' ? (commitFiles ? commitQueryKey : null) : activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase const comparisonRangeKey = comparison.files ? comparison.key : null;
? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + getRuntimeKey()
: null;
const comparisonPathsKey = React.useMemo( const comparisonPathsKey = React.useMemo(
() => (activeDiffScope === 'branch' || activeDiffScope === 'commit' ? Array.from(expandedFiles).sort().join('\0') : ''), () => (activeDiffScope === 'branch' || activeDiffScope === 'commit' ? Array.from(expandedFiles).sort().join('\0') : ''),
[activeDiffScope, expandedFiles] [activeDiffScope, expandedFiles]
@@ -1322,30 +1269,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
const fetchComparisonDiffEntry = React.useCallback( const fetchComparisonDiffEntry = React.useCallback(
async (filePath: string): Promise<ComparisonDiffResult> => { async (filePath: string): Promise<ComparisonDiffResult> => {
if (!effectiveDirectory) return EMPTY_COMPARISON_DIFF;
try { try {
if (activeDiffScope === 'commit' && selectedCommitHash) { const response = await loadComparisonDiff(filePath, loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES);
const response = await getGitCommitDiff(effectiveDirectory, {
hash: selectedCommitHash, path: filePath,
previousPath: commitFilesByPath.get(filePath)?.previousPath,
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
});
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
}
if (!branchBase || !currentBranch) return EMPTY_COMPARISON_DIFF;
const response = await getGitRangeDiff(effectiveDirectory, {
base: branchBase,
head: currentBranch,
path: filePath,
includeWorkingTree: true,
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
});
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') }; return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
} catch (error) { } catch (error) {
return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') }; return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') };
} }
}, },
[activeDiffScope, branchBase, commitFilesByPath, currentBranch, effectiveDirectory, loadFullFiles, selectedCommitHash, t] [loadComparisonDiff, loadFullFiles, t]
); );
const comparisonDiffData = useRangeKeyedCache<ComparisonDiffResult>( const comparisonDiffData = useRangeKeyedCache<ComparisonDiffResult>(
@@ -1361,8 +1292,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
const changedFiles: FileEntry[] = React.useMemo(() => { const changedFiles: FileEntry[] = React.useMemo(() => {
if (activeDiffScope === 'commit') { if (activeDiffScope === 'commit') {
return (commitFiles ?? []).map((file) => ({ return (commitFiles ?? []).map((file) => ({
path: file.path, index: '', working_dir: file.changeType, path: file.path, index: '', working_dir: file.status,
insertions: file.insertions, deletions: file.deletions, isNew: file.changeType === 'A', insertions: file.insertions, deletions: file.deletions, isNew: file.status === 'A',
})); }));
} }
if (activeDiffScope === 'branch') { if (activeDiffScope === 'branch') {
@@ -2033,7 +1964,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (commitFilesError) { if (commitFilesError) {
return <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center"> return <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<p className="typography-meta text-muted-foreground">{commitFilesError}</p> <p className="typography-meta text-muted-foreground">{commitFilesError}</p>
<Button variant="outline" size="sm" onClick={() => setCommitFilesRetry((value) => value + 1)}>{t('diffView.actions.retry')}</Button> <Button variant="outline" size="sm" onClick={() => void comparison.refresh()}>{t('diffView.actions.retry')}</Button>
</div>; </div>;
} }
if (!selectedCommitHash && !commitComparison.loading) { if (!selectedCommitHash && !commitComparison.loading) {
@@ -2076,7 +2007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => reloadBranchFiles()} onClick={() => void comparison.refresh()}
> >
{t('diffView.actions.retry')} {t('diffView.actions.retry')}
</Button> </Button>
@@ -3,7 +3,6 @@ import { createRoot, type Root } from 'react-dom/client';
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { import {
branchRangeKey,
coerceDiffScope, coerceDiffScope,
isBranchScopeAvailable, isBranchScopeAvailable,
isBranchScopeDefinitelyUnavailable, isBranchScopeDefinitelyUnavailable,
@@ -130,20 +129,6 @@ describe('isBranchScopeDefinitelyUnavailable', () => {
}); });
}); });
describe('branchRangeKey', () => {
test('distinguishes bases, heads, and directories for the same path', () => {
// The same file path can carry different diff content per range; a cache
// keyed by path alone would leak a previous branch's patch.
const keys = [
branchRangeKey('/repo', 'main', 'feature-a'),
branchRangeKey('/repo', 'develop', 'feature-a'),
branchRangeKey('/repo', 'main', 'feature-b'),
branchRangeKey('/other', 'main', 'feature-a'),
];
expect(new Set(keys).size).toBe(4);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// useRangeKeyedCache // useRangeKeyedCache
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -62,15 +62,6 @@ export const coerceDiffScope = <T extends string>(
branchScopeAvailable: boolean branchScopeAvailable: boolean
): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope); ): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope);
/**
* Identity of one `base...head` range in one repository. Range-cache entries
* are only valid within a single range: the same file path can carry different
* content under a different base or head, so a cache keyed by path alone leaks
* stale patches across branch and base switches.
*/
export const branchRangeKey = (directory: string, base: string, head: string): string =>
JSON.stringify([directory, base, head]);
/** /**
* Bounded per-directory retry for a request whose failure leaves no result and * Bounded per-directory retry for a request whose failure leaves no result and
* no signal beyond the in-flight flag settling back to false. * no signal beyond the in-flight flag settling back to false.
@@ -2,8 +2,9 @@ import React, { act, useState } from 'react';
import { test, expect } from 'bun:test'; import { test, expect } from 'bun:test';
import { Window } from 'happy-dom'; import { Window } from 'happy-dom';
test('allows repeated base selection with search and keyboard navigation', async () => { const checkBranchSelection = async (mobile: boolean, tablet = false) => {
const dom = new Window({ url: 'http://localhost' }); const dom = new Window({ url: 'http://localhost' });
if (mobile && !tablet) dom.happyDOM.setWindowSize({ width: 390, height: 844 });
const originals = new Map<string, PropertyDescriptor | undefined>(); const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = { const globals = {
window: dom, window: dom,
@@ -38,6 +39,7 @@ test('allows repeated base selection with search and keyboard navigation', async
function Harness() { function Harness() {
const [base, setBase] = useState<string | null>(null); const [base, setBase] = useState<string | null>(null);
return <BranchComparisonSelector return <BranchComparisonSelector
mobile={mobile}
branches={['feature', 'main', 'parent', 'remotes/origin/main']} branches={['feature', 'main', 'parent', 'remotes/origin/main']}
currentBranch="feature" currentBranch="feature"
base={base} base={base}
@@ -60,6 +62,11 @@ test('allows repeated base selection with search and keyboard navigation', async
try { try {
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>)); await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
await act(async () => trigger().click()); await act(async () => trigger().click());
if (mobile) {
if (tablet) expect(document.querySelector('[role="dialog"]')).toBeNull();
else expect(document.querySelector('[role="dialog"]')).not.toBeNull();
if (!tablet) expect(document.activeElement).not.toBe(document.querySelector('input'));
}
expect(document.querySelector('[data-value="refs/heads/feature"]')).toBeNull(); expect(document.querySelector('[data-value="refs/heads/feature"]')).toBeNull();
await press('ArrowDown'); await press('ArrowDown');
const afterDown = selectedRef(); const afterDown = selectedRef();
@@ -95,6 +102,10 @@ test('allows repeated base selection with search and keyboard navigation', async
trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true, bubbles: true })); trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true, bubbles: true }));
}); });
expect(document.querySelector('input')).toBeNull(); expect(document.querySelector('input')).toBeNull();
await act(async () => trigger().click());
await press('Escape');
expect(document.querySelector('input')).toBeNull();
expect(choices).toHaveLength(3);
} finally { } finally {
await act(async () => root.unmount()); await act(async () => root.unmount());
await dom.happyDOM.abort(); await dom.happyDOM.abort();
@@ -103,4 +114,9 @@ test('allows repeated base selection with search and keyboard navigation', async
else Reflect.deleteProperty(globalThis, name); else Reflect.deleteProperty(globalThis, name);
} }
} }
}); };
for (const mobile of [false, true]) {
test(`allows repeated base selection with search and keyboard navigation (mobile=${mobile})`, () => checkBranchSelection(mobile));
}
test('keeps the mobile branch picker anchored on tablets', () => checkBranchSelection(true, true));
@@ -4,7 +4,9 @@ import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useTabletLayout } from '@/lib/device';
import { rankByQuery } from '@/lib/search/fuzzySearch'; import { rankByQuery } from '@/lib/search/fuzzySearch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { branchRefLabel } from './baseBranch'; import { branchRefLabel } from './baseBranch';
@@ -14,44 +16,51 @@ interface BranchComparisonSelectorProps {
currentBranch: string | null; currentBranch: string | null;
base: string | null; base: string | null;
onSelect: (ref: string) => void; onSelect: (ref: string) => void;
mobile?: boolean;
} }
export function BranchComparisonSelector({ branches, currentBranch, base, onSelect }: BranchComparisonSelectorProps) { export function BranchComparisonSelector({ branches, currentBranch, base, onSelect, mobile = false }: BranchComparisonSelectorProps) {
const { t } = useI18n(); const { t } = useI18n();
const tabletLayout = useTabletLayout();
const useSheet = mobile && !tabletLayout.enabled;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const label = base ? branchRefLabel(base) : t('gitView.pr.field.baseBranch'); const label = base ? branchRefLabel(base) : t('gitView.pr.field.baseBranch');
return ( const changeOpen = (nextOpen: boolean) => {
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) setSearch(''); if (!nextOpen) setSearch('');
}}> };
<DropdownMenuTrigger asChild> const trigger = (
<Button <Button
variant="outline" variant="outline"
className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')} className={cn(dropdownTriggerVariants({ size: mobile ? 'default' : 'sm' }), 'min-w-0 max-w-48')}
data-mobile-comparison-trigger={mobile || undefined}
aria-label={t('gitView.pr.field.baseBranch')} aria-label={t('gitView.pr.field.baseBranch')}
aria-haspopup={useSheet ? 'dialog' : undefined}
aria-expanded={useSheet ? open : undefined}
title={label} title={label}
disabled={!currentBranch} disabled={!currentBranch}
onClick={useSheet ? () => changeOpen(true) : undefined}
> >
<Icon name="git-branch" className="size-3.5" /> <Icon name="git-branch" className="size-3.5" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
<Icon name="arrow-down-s" className="size-3.5" /> <Icon name="arrow-down-s" className="size-3.5" />
</Button> </Button>
</DropdownMenuTrigger> );
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-0"> const picker = (
<Command shouldFilter={false} onKeyDown={(event) => { <Command shouldFilter={false} className={mobile ? '[&_[data-slot=command-input-wrapper]]:h-11' : undefined} onKeyDown={(event) => {
if (event.key !== 'Escape') event.stopPropagation(); if (event.key !== 'Escape') event.stopPropagation();
}}> }}>
<CommandInput <CommandInput
autoFocus autoFocus={!useSheet}
className={mobile ? 'h-11' : undefined}
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
placeholder={t('gitView.branch.searchPlaceholder')} placeholder={t('gitView.branch.searchPlaceholder')}
aria-label={t('gitView.branch.searchPlaceholder')} aria-label={t('gitView.branch.searchPlaceholder')}
/> />
<CommandList> <CommandList className={mobile ? 'max-h-[min(45dvh,24rem)]' : undefined}>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty> <CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup> <CommandGroup>
{open && rankByQuery( {open && rankByQuery(
@@ -65,10 +74,9 @@ export function BranchComparisonSelector({ branches, currentBranch, base, onSele
search, search,
(branch) => [branch.label], (branch) => [branch.label],
).map((branch) => ( ).map((branch) => (
<CommandItem key={branch.ref} value={branch.ref} onSelect={() => { <CommandItem key={branch.ref} value={branch.ref} className={mobile ? 'min-h-11' : undefined} onSelect={() => {
onSelect(branch.ref); onSelect(branch.ref);
setOpen(false); changeOpen(false);
setSearch('');
}}> }}>
<span className="min-w-0 flex-1 truncate" title={branch.ref}>{branch.label}</span> <span className="min-w-0 flex-1 truncate" title={branch.ref}>{branch.label}</span>
{(branch.ref === base || branch.label === base) && <Icon name="check" className="size-3.5" />} {(branch.ref === base || branch.label === base) && <Icon name="check" className="size-3.5" />}
@@ -77,6 +85,20 @@ export function BranchComparisonSelector({ branches, currentBranch, base, onSele
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
);
if (useSheet) {
return <>
{trigger}
<MobileOverlayPanel open={open} title={t('gitView.pr.field.baseBranch')} onClose={() => changeOpen(false)}>
{picker}
</MobileOverlayPanel>
</>;
}
return (
<DropdownMenu open={open} onOpenChange={changeOpen}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-0">
{picker}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@@ -3,8 +3,9 @@ import { expect, test } from 'bun:test';
import { Window } from 'happy-dom'; import { Window } from 'happy-dom';
import type { GitLogEntry } from '@/lib/api/types'; import type { GitLogEntry } from '@/lib/api/types';
test('shows commit metadata and shares repeated searched selections between two pickers', async () => { const checkCommitSelection = async (mobile: boolean, tablet = false) => {
const dom = new Window({ url: 'http://localhost' }); const dom = new Window({ url: 'http://localhost' });
if (mobile && !tablet) dom.happyDOM.setWindowSize({ width: 390, height: 844 });
const originals = new Map<string, PropertyDescriptor | undefined>(); const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = { const globals = {
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, window: dom, document: dom.document, navigator: dom.navigator, location: dom.location,
@@ -34,7 +35,7 @@ test('shows commit metadata and shares repeated searched selections between two
function Harness() { function Harness() {
const [hash, setHash] = useState<string | null>(null); const [hash, setHash] = useState<string | null>(null);
return <>{['changes', 'walkthrough'].map((name) => <section key={name} data-picker={name}> return <>{['changes', 'walkthrough'].map((name) => <section key={name} data-picker={name}>
<CommitComparisonSelector commits={commits} selectedHash={hash} loading={false} error={null} <CommitComparisonSelector mobile={mobile} commits={commits} selectedHash={hash} loading={false} error={null}
onRefresh={() => { refreshes += 1; }} onRefresh={() => { refreshes += 1; }}
onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} /> onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} />
</section>)}</>; </section>)}</>;
@@ -55,6 +56,11 @@ test('shows commit metadata and shares repeated searched selections between two
try { try {
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>)); await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
await act(async () => trigger('changes').click()); await act(async () => trigger('changes').click());
if (mobile) {
if (tablet) expect(document.querySelector('[role="dialog"]')).toBeNull();
else expect(document.querySelector('[role="dialog"]')).not.toBeNull();
if (!tablet) expect(document.activeElement).not.toBe(input());
}
const first = document.querySelector('[cmdk-item]'); const first = document.querySelector('[cmdk-item]');
expect(first?.textContent).toContain('fix: first commit'); expect(first?.textContent).toContain('fix: first commit');
expect(first?.textContent).toContain('Test Author'); expect(first?.textContent).toContain('Test Author');
@@ -85,4 +91,9 @@ test('shows commit metadata and shares repeated searched selections between two
else Reflect.deleteProperty(globalThis, name); else Reflect.deleteProperty(globalThis, name);
} }
} }
}); };
for (const mobile of [false, true]) {
test(`shows commit metadata and shares repeated searched selections between two pickers (mobile=${mobile})`, () => checkCommitSelection(mobile));
}
test('keeps the mobile commit picker anchored on tablets', () => checkCommitSelection(true, true));
@@ -5,7 +5,9 @@ import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useTabletLayout } from '@/lib/device';
import { rankByQuery } from '@/lib/search/fuzzySearch'; import { rankByQuery } from '@/lib/search/fuzzySearch';
import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -18,30 +20,36 @@ interface CommitComparisonSelectorProps {
error: string | null; error: string | null;
onSelect: (commit: GitLogEntry) => void; onSelect: (commit: GitLogEntry) => void;
onRefresh: () => void; onRefresh: () => void;
mobile?: boolean;
} }
export function CommitComparisonSelector({ commits, selectedHash, loading, error, onSelect, onRefresh }: CommitComparisonSelectorProps) { export function CommitComparisonSelector({ commits, selectedHash, loading, error, onSelect, onRefresh, mobile = false }: CommitComparisonSelectorProps) {
const { t } = useI18n(); const { t } = useI18n();
const tabletLayout = useTabletLayout();
const useSheet = mobile && !tabletLayout.enabled;
const timeFormat = useUIStore((state) => state.timeFormatPreference); const timeFormat = useUIStore((state) => state.timeFormatPreference);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
return ( const changeOpen = (value: boolean) => {
<DropdownMenu open={open} onOpenChange={(value) => {
setOpen(value); setOpen(value);
if (!value) setSearch(''); if (!value) setSearch('');
else if (!loading) onRefresh(); else if (!loading) onRefresh();
}}> };
<DropdownMenuTrigger asChild> const trigger = (
<Button variant="outline" className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')} <Button variant="outline" className={cn(dropdownTriggerVariants({ size: mobile ? 'default' : 'sm' }), 'min-w-0 max-w-48')}
data-mobile-comparison-trigger={mobile || undefined}
onClick={useSheet ? () => changeOpen(true) : undefined}
aria-haspopup={useSheet ? 'dialog' : undefined}
aria-expanded={useSheet ? open : undefined}
aria-label={t('commitComparison.select')} title={selectedHash ?? t('commitComparison.select')}> aria-label={t('commitComparison.select')} title={selectedHash ?? t('commitComparison.select')}>
<Icon name="git-commit" className="size-3.5" /> <Icon name="git-commit" className="size-3.5" />
<span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span> <span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span>
<Icon name="arrow-down-s" className="size-3.5" /> <Icon name="arrow-down-s" className="size-3.5" />
</Button> </Button>
</DropdownMenuTrigger> );
<DropdownMenuContent align="start" className="w-[32rem] max-w-[calc(100vw-2rem)] p-0"> const picker = (
<Command shouldFilter={false} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}> <Command shouldFilter={false} className={mobile ? '[&_[data-slot=command-input-wrapper]]:h-11' : undefined} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}>
<CommandInput autoFocus value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} /> <CommandInput autoFocus={!useSheet} className={mobile ? 'h-11' : undefined} value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} />
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 p-4 typography-meta text-muted-foreground"> <div className="flex items-center justify-center gap-2 p-4 typography-meta text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingChanges')} <Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingChanges')}
@@ -50,20 +58,23 @@ export function CommitComparisonSelector({ commits, selectedHash, loading, error
<div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground"> <div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground">
<span>{t('commitComparison.loadError')}</span> <span>{t('commitComparison.loadError')}</span>
<span className="max-w-full break-words">{error}</span> <span className="max-w-full break-words">{error}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>{t('diffView.actions.retry')}</Button> <Button variant="outline" size={mobile ? 'lg' : 'sm'} onClick={onRefresh}>{t('diffView.actions.retry')}</Button>
</div> </div>
) : ( ) : (
<CommandList> <CommandList className={mobile ? 'max-h-[min(45dvh,24rem)]' : undefined}>
<CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty> <CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty>
<CommandGroup> <CommandGroup>
{open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => ( {open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => (
<CommandItem key={commit.hash} value={commit.hash} onSelect={() => { onSelect(commit); setOpen(false); setSearch(''); }}> <CommandItem key={commit.hash} value={commit.hash} className={mobile ? 'min-h-11' : undefined} onSelect={() => { onSelect(commit); changeOpen(false); }}>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate typography-ui-label font-semibold" title={commit.message}>{commit.message}</div> <div className="truncate typography-ui-label font-semibold" title={commit.message}>{commit.message}</div>
<div className="truncate typography-meta text-muted-foreground"> <div className="flex min-w-0 gap-1 typography-meta text-muted-foreground">
<span className="min-w-0 truncate">
{commit.author_name} · {Number.isNaN(new Date(commit.date).getTime()) ? commit.date : formatDateTimeForPreference(new Date(commit.date), timeFormat, { {commit.author_name} · {Number.isNaN(new Date(commit.date).getTime()) ? commit.date : formatDateTimeForPreference(new Date(commit.date), timeFormat, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})} · {commit.hash.slice(0, 8)} })}
</span>
<span className="shrink-0">· {commit.hash.slice(0, 8)}</span>
</div> </div>
</div> </div>
{commit.hash === selectedHash && <Icon name="check" className="size-3.5" />} {commit.hash === selectedHash && <Icon name="check" className="size-3.5" />}
@@ -73,6 +84,20 @@ export function CommitComparisonSelector({ commits, selectedHash, loading, error
</CommandList> </CommandList>
)} )}
</Command> </Command>
);
if (useSheet) {
return <>
{trigger}
<MobileOverlayPanel open={open} title={t('commitComparison.select')} onClose={() => changeOpen(false)}>
{picker}
</MobileOverlayPanel>
</>;
}
return (
<DropdownMenu open={open} onOpenChange={changeOpen}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[32rem] max-w-[calc(100vw-2rem)] p-0">
{picker}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@@ -0,0 +1,135 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { GitComparisonSource } from './useGitComparison';
test('comparison reads preserve scope, report failures, retry, and stop while hidden', async () => {
const dom = new Window({ url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = {
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location,
localStorage: dom.localStorage,
Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node,
Event: dom.Event, CustomEvent: dom.CustomEvent,
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const originalFetch = globalThis.fetch;
const requests: Array<{ url: URL; resolve: (response: Response) => void }> = [];
globalThis.fetch = Object.assign((input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost');
// Hold unrelated app-store bootstrap outside this fixture. Only explicitly
// resolved comparison requests should publish data during these transitions.
if (url.pathname === '/api/fs/home' || url.pathname === '/api/session-folders') {
return new Promise<Response>(() => {});
}
return new Promise<Response>((resolve) => { requests.push({ url, resolve }); });
}, originalFetch);
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('@/lib/i18n');
const { useGitComparison } = await import('./useGitComparison');
type Capture = { current: ReturnType<typeof useGitComparison> | null };
const captured: Capture = { current: null };
let directory = '/repo-a';
let source: GitComparisonSource = { kind: 'branch', baseRef: 'refs/heads/main', headRef: 'feature' };
let enabled = false;
let revision = '1';
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
function Harness() {
captured.current = useGitComparison(directory, source, enabled, revision);
return null;
}
const current = () => {
if (!captured.current) throw new Error('Comparison did not render');
return captured.current;
};
const render = () => act(async () => { root.render(<I18nProvider><Harness /></I18nProvider>); });
const finish = (index: number, response: Response) => act(async () => { requests[index].resolve(response); });
try {
await render();
expect(requests.map(({ url }) => url.pathname)).toEqual([]);
enabled = true;
await render();
expect(requests).toHaveLength(1);
expect(requests[0].url.searchParams.get('base')).toBe('refs/heads/main');
expect(requests[0].url.searchParams.get('includeWorkingTree')).toBe('true');
await finish(0, Response.json({ files: [{ path: 'a.ts', status: 'M' }] }));
expect(current().files?.map((file) => file.path)).toEqual(['a.ts']);
const oldRefresh = current().refresh;
const oldFetchDiff = current().fetchDiff;
const patch = current().fetchDiff('a.ts');
await act(async () => { await Promise.resolve(); });
expect(requests[1].url.pathname).toBe('/api/git/range-diff');
expect(requests[1].url.searchParams.get('includeWorkingTree')).toBe('true');
await finish(1, Response.json({ diff: 'branch patch' }));
expect(await patch).toEqual({ diff: 'branch patch' });
revision = '2';
await render();
expect(current().files?.map((file) => file.path)).toEqual(['a.ts']);
await finish(2, Response.json({ error: 'snapshot failed' }, { status: 500 }));
expect(current().files).toBeNull();
expect(current().error).toBe('snapshot failed');
let retry: Promise<void> | undefined;
await act(async () => { retry = current().refresh(); });
await finish(3, Response.json({ files: [] }));
await retry;
expect(current().files).toEqual([]);
expect(current().error).toBeNull();
source = { kind: 'commit', hash: 'a'.repeat(40) };
await render();
expect(current().files).toBeNull();
expect(requests[4].url.pathname).toBe('/api/git/commit-files');
await finish(4, Response.json({ files: [{ path: 'new.ts', previousPath: 'old.ts', changeType: 'R', insertions: 1, deletions: 1, isBinary: false }] }));
const commitPatch = current().fetchDiff('new.ts', 20);
await act(async () => { await Promise.resolve(); });
expect(requests[5].url.pathname).toBe('/api/git/commit-diff');
expect(requests[5].url.searchParams.get('hash')).toBe('a'.repeat(40));
expect(requests[5].url.searchParams.get('previousPath')).toBe('old.ts');
expect(requests[5].url.searchParams.get('context')).toBe('20');
await finish(5, Response.json({ diff: 'commit patch' }));
expect(await commitPatch).toEqual({ diff: 'commit patch' });
await oldRefresh();
await expect(oldFetchDiff('a.ts')).rejects.toThrow();
expect(requests).toHaveLength(6);
source = { kind: 'branch', baseRef: 'main', headRef: 'feature' };
await render();
directory = '/repo-b';
await render();
await finish(7, Response.json({ files: [{ path: 'b.ts', status: 'A' }] }));
await finish(6, Response.json({ files: [{ path: 'stale.ts', status: 'D' }] }));
expect(current().files?.map((file) => file.path)).toEqual(['b.ts']);
const refreshBeforeHide = current().refresh;
const fetchBeforeHide = current().fetchDiff;
enabled = false;
revision = '3';
await render();
await current().refresh();
await refreshBeforeHide();
await expect(fetchBeforeHide('b.ts')).rejects.toThrow();
expect(requests).toHaveLength(8);
expect(current().files?.map((file) => file.path)).toEqual(['b.ts']);
enabled = true;
await render();
expect(requests).toHaveLength(9);
await finish(8, Response.json({ files: [{ path: 'b.ts', status: 'A' }] }));
} finally {
await act(async () => root.unmount());
globalThis.fetch = originalFetch;
await dom.happyDOM.abort();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { GitDiffResponse } from '@/lib/api/types';
import { getCommitFiles, getGitCommitDiff, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { useI18n } from '@/lib/i18n';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { WalkthroughSource } from '@/lib/walkthrough/types';
import { useGitStore } from '@/stores/useGitStore';
export type GitComparisonSource = Extract<WalkthroughSource, { kind: 'branch' | 'commit' }>;
export interface GitComparisonFile {
path: string;
status: string;
previousPath?: string;
insertions: number;
deletions: number;
}
type ComparisonFiles =
| { key: string; status: 'loading' }
| { key: string; status: 'ready'; files: GitComparisonFile[] }
| { key: string; status: 'error'; message: string };
/** File-list authority shared by the stacked desktop view and mobile drill-down. */
export function useGitComparison(directory: string | null, source: GitComparisonSource | null, enabled = true, revision = '') {
const { t } = useI18n();
const runtimeKey = useGitStore((state) => state.runtimeKey);
const key = directory && source ? JSON.stringify([runtimeKey, directory, source]) : null;
const sourceRef = useRef({ key, source, enabled });
sourceRef.current = { key, source, enabled };
const [result, setResult] = useState<ComparisonFiles | null>(null);
const generation = useRef(0);
const refresh = useCallback(async () => {
const { key: targetKey, source: target, enabled: active } = sourceRef.current;
if (!enabled || !active || !key || targetKey !== key || !directory || !target) return;
const request = ++generation.current;
const runtime = getRuntimeKey();
setResult((previous) => previous?.key === key && previous.status === 'ready' ? previous : { key, status: 'loading' });
try {
const files: GitComparisonFile[] = target.kind === 'branch'
? (await getGitRangeFiles(directory, { base: target.baseRef, head: target.headRef, includeWorkingTree: true }))
.map((file) => ({ ...file, insertions: 0, deletions: 0 }))
: (await getCommitFiles(directory, target.hash)).files
.map((file) => ({ path: file.path, status: file.changeType, previousPath: file.previousPath, insertions: file.insertions, deletions: file.deletions }));
if (generation.current !== request || getRuntimeKey() !== runtime) return;
setResult({ key, status: 'ready', files });
} catch (error) {
if (generation.current !== request || getRuntimeKey() !== runtime) return;
setResult({ key, status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') });
}
}, [directory, enabled, key, t]);
useEffect(() => {
void refresh();
return () => { generation.current += 1; };
}, [refresh, revision]);
const current = result?.key === key ? result : null;
const files = current?.status === 'ready' ? current.files : null;
const filesByPath = useMemo(() => new Map((files ?? []).map((file) => [file.path, file])), [files]);
const fetchDiff = useCallback(async (filePath: string, contextLines = 3): Promise<GitDiffResponse> => {
const { key: targetKey, source: target, enabled: active } = sourceRef.current;
const file = filesByPath.get(filePath);
if (!directory || targetKey !== key || !target || !file || !enabled || !active) throw new Error(t('diffView.state.failedToLoadDiff'));
return target.kind === 'branch'
? getGitRangeDiff(directory, { base: target.baseRef, head: target.headRef, path: filePath, contextLines, includeWorkingTree: true })
: getGitCommitDiff(directory, { hash: target.hash, path: filePath, previousPath: file.previousPath, contextLines });
}, [directory, enabled, filesByPath, key, t]);
return {
key,
files,
loading: Boolean(enabled && key && (!current || current.status === 'loading')),
error: current?.status === 'error' ? current.message : null,
refresh,
fetchDiff,
};
}
@@ -10,6 +10,9 @@ const PATCH_DIFF_CACHE_LIMIT = 64;
const DEFAULT_PATCH_CONTEXT_LINES = 3; const DEFAULT_PATCH_CONTEXT_LINES = 3;
const patchFileDiffCache = new Map<string, FileDiffMetadata>(); const patchFileDiffCache = new Map<string, FileDiffMetadata>();
export const isBinaryPatch = (patch: string): boolean =>
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
export const fileDiffFromPatch = ( export const fileDiffFromPatch = (
file: string, file: string,
patch: string, patch: string,
+11 -2
View File
@@ -42,8 +42,8 @@ refresh attempt per opening, so a failed first load cannot create a retry loop.
### UI state stores ### UI state stores
`useCommitSelectionStore.ts` shares the selected commit between Changes and `useCommitSelectionStore.ts` shares the selected commit between desktop/mobile
walkthrough. Choices are session-only and keyed by runtime, directory, and Changes and walkthrough. Choices are session-only and keyed by runtime, directory, and
checked-out branch, with at most 100 remembered choices. The picker history checked-out branch, with at most 100 remembered choices. The picker history
belongs to `useCommitComparison`, loads only while Commit mode is active, and belongs to `useCommitComparison`, loads only while Commit mode is active, and
is limited to the latest 50 commits. History failure stays distinct from an is limited to the latest 50 commits. History failure stays distinct from an
@@ -51,6 +51,15 @@ empty list; stale directory/runtime requests cannot replace current history or
selection. A refreshed list preserves an explicit selection even when newer selection. A refreshed list preserves an explicit selection even when newer
commits have pushed it beyond the latest 50. commits have pushed it beyond the latest 50.
`hooks/useGitComparison.ts` owns the local file-list state used by desktop and
mobile comparisons. Its key contains runtime, directory, and the complete
branch/commit source. A source change hides the old list immediately; failed
reads remain errors, and manual retries cannot publish into a superseded scope.
The hook also resolves per-file patch requests, including a commit rename's
previous path. Views own their lazy patch caches through `useRangeKeyedCache`.
Mobile requests only the active detail path and suspends reads while its
keep-alive workspace pane is hidden.
Examples: Examples:
- `useUIStore.ts` - `useUIStore.ts`
+6
View File
@@ -56,6 +56,12 @@
min-width: 36px; min-width: 36px;
} }
/* The Changes section and source pickers take their height from dropdownTriggerVariants,
rather than the generic mobile button minimum. */
:root.mobile-pointer:not(.desktop-runtime) button[data-mobile-comparison-trigger] {
min-height: 0;
}
/* Composer footer action buttons (sessions / attach / auto-accept): hug the /* Composer footer action buttons (sessions / attach / auto-accept): hug the
icon so the group stays tight. The container only renders in the mobile icon so the group stays tight. The container only renders in the mobile
JSX, so no pointer/runtime gating is needed; !important overrides both the JSX, so no pointer/runtime gating is needed; !important overrides both the
+1 -1
View File
@@ -134,7 +134,7 @@ The following functions are internal helpers used by exported functions:
### Runtime availability of range diffs ### Runtime availability of range diffs
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code. - `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
- Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. The shared Changes toolbar and walkthrough expose it on their existing desktop/tablet surfaces. The phone-specific Changes surface and the VS Code Git bridge keep their existing modes; Commit mode is not offered there. The HTTP operation is available to web, Electron, hosted mobile, and Capacitor clients. - Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. Desktop Changes, mobile Changes, and the existing walkthrough surface share branch/commit comparison semantics. Mobile Changes uses the same selectors and `useGitComparison` file-list owner, with a read-only list-to-detail flow. VS Code keeps its existing modes because its Git bridge does not provide these comparison operations. The HTTP operations are available to web, Electron, hosted mobile, and Capacitor clients.
### Staged and unstaged change handling ### Staged and unstaged change handling
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files. - `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.