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:
co-authored by
gaojunran
parent
0b899d1153
commit
fce3f174d2
@@ -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.
|
||||
- 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 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3,9 +3,15 @@ import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
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 { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
||||
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 { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
|
||||
import { SyncActions } from '@/components/views/git/SyncActions';
|
||||
@@ -13,6 +19,13 @@ import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
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 { useI18n } from '@/lib/i18n';
|
||||
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 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 isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
@@ -51,21 +87,32 @@ type MobileChangesSurfaceProps = {
|
||||
/** When provided, the list header gets a close X that calls this. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* When set (and non-null), the surface opens directly into the per-file diff view for this
|
||||
* relative path. Updating it (incl. setting it to a different path while open) routes the
|
||||
* surface to that diff. Setting it back to null leaves the user on the current internal route.
|
||||
* A new request object opens its working-tree diff, including repeated requests
|
||||
* for the same path. Reopening the drawer keeps the same object and navigation.
|
||||
*/
|
||||
initialDiffPath?: string | null;
|
||||
initialDiffStaged?: boolean;
|
||||
initialDiff?: { path: string; staged: boolean } | null;
|
||||
/** 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 { git } = useRuntimeAPIs();
|
||||
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
|
||||
// When the root is not itself a repository, changes come from the resolved
|
||||
// nested repository instead.
|
||||
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
|
||||
const { rootIsGitRepo, gitDirectory, nestedRepos } = repository;
|
||||
const currentDirectory = gitDirectory ?? rootDirectory;
|
||||
const status = useGitStatus(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 setDiff = useGitStore((state) => state.setDiff);
|
||||
|
||||
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>(
|
||||
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }),
|
||||
);
|
||||
const runtimeKey = useGitStore((state) => state.runtimeKey);
|
||||
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
|
||||
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
|
||||
// a different file mid-session.
|
||||
React.useEffect(() => {
|
||||
if (!initialDiffPath) return;
|
||||
setRoute((current) => (
|
||||
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged
|
||||
? current
|
||||
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged }
|
||||
));
|
||||
}, [initialDiffPath, initialDiffStaged]);
|
||||
if (!initialDiff?.path) return;
|
||||
// A new external target is a working-tree diff, regardless of the last
|
||||
// comparison mode. Changing directories alone must not replay this target.
|
||||
setNavigation({ ownerKey: ownerKeyRef.current, mode: 'working', route: { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } });
|
||||
}, [initialDiff]);
|
||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
||||
const [commitMessage, setCommitMessage] = React.useState('');
|
||||
@@ -110,6 +177,54 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
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 files = status?.files ?? [];
|
||||
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) => {
|
||||
if (!currentDirectory) return;
|
||||
try {
|
||||
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
|
||||
await git.createBranch(currentDirectory, branch, currentBranch ?? 'HEAD');
|
||||
await git.checkoutBranch(currentDirectory, branch);
|
||||
if (remote) {
|
||||
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'));
|
||||
throw error;
|
||||
}
|
||||
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
|
||||
}, [currentBranch, currentDirectory, git, refreshStatusAndBranches, t]);
|
||||
|
||||
const refreshRemotes = React.useCallback(async () => {
|
||||
if (!currentDirectory) {
|
||||
@@ -231,17 +346,17 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) return;
|
||||
if (!currentDirectory || !visible) return;
|
||||
setActiveDirectory(currentDirectory);
|
||||
void ensureAll(currentDirectory, git);
|
||||
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
|
||||
}, [currentDirectory, ensureAll, git, setActiveDirectory, visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshRemotes();
|
||||
}, [refreshRemotes]);
|
||||
if (visible) void refreshRemotes();
|
||||
}, [refreshRemotes, visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory || changeEntries.length === 0) return;
|
||||
if (!visible || mode !== 'working' || !currentDirectory || changeEntries.length === 0) return;
|
||||
const orderedPaths = Array.from(new Set([
|
||||
...stagedChangeEntries.map((entry) => entry.path),
|
||||
...visibleChangePaths,
|
||||
@@ -252,9 +367,10 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
|
||||
}, 120);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
||||
}, [changeEntries, currentDirectory, git, mode, prefetchDiffs, stagedChangeEntries, visibleChangePaths, visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (route.type !== 'diff') {
|
||||
setDiffLoadError(null);
|
||||
return;
|
||||
@@ -285,7 +401,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
|
||||
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff, visible]);
|
||||
|
||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
@@ -349,7 +465,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
|
||||
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
|
||||
setRoute({ type: 'diff', path, staged });
|
||||
}, []);
|
||||
}, [setRoute]);
|
||||
|
||||
const handleRevertFile = React.useCallback(async (filePath: string) => {
|
||||
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 (
|
||||
<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 ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -594,35 +763,68 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
||||
<BranchSelector
|
||||
currentBranch={status?.current}
|
||||
localBranches={localBranches}
|
||||
remoteBranches={remoteBranches}
|
||||
branchInfo={branches?.branches}
|
||||
currentBranchAhead={status?.ahead}
|
||||
onCheckout={(branch) => void handleCheckoutBranch(branch)}
|
||||
onCreate={handleCreateBranch}
|
||||
<DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
|
||||
<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
|
||||
currentBranch={status?.current}
|
||||
localBranches={localBranches}
|
||||
remoteBranches={remoteBranches}
|
||||
branchInfo={branches?.branches}
|
||||
currentBranchAhead={status?.ahead}
|
||||
onCheckout={(branch) => void handleCheckoutBranch(branch)}
|
||||
onCreate={handleCreateBranch}
|
||||
remotes={effectiveRemotes}
|
||||
disabled={isLoadingStatus}
|
||||
directory={currentDirectory}
|
||||
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
|
||||
/>
|
||||
</div>
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
remotes={effectiveRemotes}
|
||||
disabled={isLoadingStatus}
|
||||
directory={currentDirectory}
|
||||
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
|
||||
onFetch={(remote) => void handleSyncAction('fetch', remote)}
|
||||
onSync={(remote) => void handleSyncAction('sync', remote)}
|
||||
disabled={commitAction !== null || isLoadingStatus}
|
||||
aheadCount={status?.ahead ?? 0}
|
||||
behindCount={status?.behind ?? 0}
|
||||
trackingRemoteName={status?.tracking?.split('/')[0]}
|
||||
hasUncommittedChanges={changeEntries.length > 0}
|
||||
/>
|
||||
</div>
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
remotes={effectiveRemotes}
|
||||
onFetch={(remote) => void handleSyncAction('fetch', remote)}
|
||||
onSync={(remote) => void handleSyncAction('sync', remote)}
|
||||
disabled={commitAction !== null || isLoadingStatus}
|
||||
aheadCount={status?.ahead ?? 0}
|
||||
behindCount={status?.behind ?? 0}
|
||||
trackingRemoteName={status?.tracking?.split('/')[0]}
|
||||
hasUncommittedChanges={changeEntries.length > 0}
|
||||
/>
|
||||
</header>
|
||||
{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">
|
||||
{/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */}
|
||||
<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<{
|
||||
path: string;
|
||||
diff: { original: string; modified: string; isBinary?: boolean } | null;
|
||||
subtitle?: string;
|
||||
diff: MobileDiffData | null;
|
||||
fileExists: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
|
||||
}> = ({ path, subtitle, diff, fileExists, error, onBack, onRetry }) => {
|
||||
const { t } = useI18n();
|
||||
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
|
||||
|
||||
@@ -757,6 +960,7 @@ const MobileDiffDetail: React.FC<{
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-2">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
|
||||
{subtitle && <p className="truncate typography-meta text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@@ -774,7 +978,7 @@ const MobileDiffDetail: React.FC<{
|
||||
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
|
||||
) : diff.isBinary ? (
|
||||
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
|
||||
) : isImageFile(path) ? (
|
||||
) : isImageFile(path) && !diff.fileDiff ? (
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||
) : (
|
||||
<ScrollShadow
|
||||
@@ -785,6 +989,7 @@ const MobileDiffDetail: React.FC<{
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
fileDiff={diff.fileDiff}
|
||||
language={language}
|
||||
fileName={path}
|
||||
renderSideBySide={false}
|
||||
@@ -797,3 +1002,30 @@ const MobileDiffDetail: React.FC<{
|
||||
</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') ? (
|
||||
<div
|
||||
// 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'}
|
||||
className={cn('h-full', tab !== 'changes' && 'hidden')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileChangesSurface
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
visible={open && tab === 'changes'}
|
||||
initialDiff={pendingChangesDiff}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
||||
entered ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
// 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:
|
||||
* - `sm` — dense surfaces: chat composer, toolbars, list rows (h-6).
|
||||
* - `default` — forms, dialogs, and settings pages (h-8).
|
||||
* - `touch` — mobile value pickers (h-11).
|
||||
*/
|
||||
export const dropdownTriggerVariants = cva(
|
||||
[
|
||||
@@ -27,6 +28,7 @@ export const dropdownTriggerVariants = cva(
|
||||
size: {
|
||||
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",
|
||||
touch: "h-11 min-h-11 px-3 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
|
||||
import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore';
|
||||
import { useCommitComparison } from '@/hooks/useCommitComparison';
|
||||
import { useGitComparison, type GitComparisonSource } from '@/hooks/useGitComparison';
|
||||
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
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 { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
|
||||
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
|
||||
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getGitRangeDiff, getGitRangeFiles, getCommitFiles, getGitCommitDiff } from '@/lib/gitApi';
|
||||
import { coerceDiffScope, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus, GitRangeFileEntry, CommitFileEntry } from '@/lib/api/types';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -47,7 +47,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
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 { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
@@ -196,9 +196,6 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number
|
||||
return 1;
|
||||
};
|
||||
|
||||
const isBinaryPatch = (patch: string): boolean =>
|
||||
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
||||
|
||||
const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((diff): diff is TurnSnapshotDiff => {
|
||||
@@ -1163,29 +1160,6 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const currentBranch = status?.current ?? null;
|
||||
const commitComparison = useCommitComparison(effectiveDirectory ?? null, currentBranch, activeDiffScope === 'commit' && !isVSCodeRuntime());
|
||||
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(() => {
|
||||
if (activeDiffScope === 'commit' && isVSCodeRuntime()) {
|
||||
setActiveDiffScope('working');
|
||||
@@ -1271,50 +1245,23 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
|
||||
|
||||
const branchQueryKey = effectiveDirectory && currentBranch && branchBase
|
||||
? JSON.stringify([getRuntimeKey(), effectiveDirectory, branchBase, currentBranch])
|
||||
: null;
|
||||
const [branchFilesResult, setBranchFilesResult] = React.useState<
|
||||
{ key: string; files: GitRangeFileEntry[] } | { key: string; error: string } | null
|
||||
>(null);
|
||||
const currentBranchFilesResult = branchFilesResult?.key === branchQueryKey ? branchFilesResult : null;
|
||||
const branchFiles = currentBranchFilesResult && 'files' in currentBranchFilesResult ? currentBranchFilesResult.files : null;
|
||||
const branchFilesError = currentBranchFilesResult && 'error' in currentBranchFilesResult ? currentBranchFilesResult.error : null;
|
||||
|
||||
// Shared by the scope/base effect and the error-state Retry button; the
|
||||
// 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]);
|
||||
const comparisonSource = React.useMemo<GitComparisonSource | null>(() => {
|
||||
if (activeDiffScope === 'commit' && selectedCommitHash) return { kind: 'commit', hash: selectedCommitHash };
|
||||
if (activeDiffScope === 'branch' && branchBase && currentBranch) return { kind: 'branch', baseRef: branchBase, headRef: currentBranch };
|
||||
return null;
|
||||
}, [activeDiffScope, branchBase, currentBranch, selectedCommitHash]);
|
||||
const comparison = useGitComparison(effectiveDirectory ?? null, comparisonSource, !isVSCodeRuntime(), activeDiffScope === 'branch' ? branchRevision : '');
|
||||
const { fetchDiff: loadComparisonDiff } = comparison;
|
||||
const commitFiles = activeDiffScope === 'commit' ? comparison.files : null;
|
||||
const commitFilesError = activeDiffScope === 'commit' ? comparison.error : null;
|
||||
const branchFiles = activeDiffScope === 'branch' ? comparison.files : null;
|
||||
const branchFilesError = activeDiffScope === 'branch' ? comparison.error : null;
|
||||
|
||||
// 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
|
||||
// local cache. Stale completions from a previous range cannot write into
|
||||
// the new range's cache (see useRangeKeyedCache).
|
||||
const comparisonRangeKey = activeDiffScope === 'commit' ? (commitFiles ? commitQueryKey : null) : activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
|
||||
? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + getRuntimeKey()
|
||||
: null;
|
||||
const comparisonRangeKey = comparison.files ? comparison.key : null;
|
||||
const comparisonPathsKey = React.useMemo(
|
||||
() => (activeDiffScope === 'branch' || activeDiffScope === 'commit' ? Array.from(expandedFiles).sort().join('\0') : ''),
|
||||
[activeDiffScope, expandedFiles]
|
||||
@@ -1322,30 +1269,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
const fetchComparisonDiffEntry = React.useCallback(
|
||||
async (filePath: string): Promise<ComparisonDiffResult> => {
|
||||
if (!effectiveDirectory) return EMPTY_COMPARISON_DIFF;
|
||||
try {
|
||||
if (activeDiffScope === 'commit' && selectedCommitHash) {
|
||||
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,
|
||||
});
|
||||
const response = await loadComparisonDiff(filePath, loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES);
|
||||
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
|
||||
} catch (error) {
|
||||
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>(
|
||||
@@ -1361,8 +1292,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'commit') {
|
||||
return (commitFiles ?? []).map((file) => ({
|
||||
path: file.path, index: '', working_dir: file.changeType,
|
||||
insertions: file.insertions, deletions: file.deletions, isNew: file.changeType === 'A',
|
||||
path: file.path, index: '', working_dir: file.status,
|
||||
insertions: file.insertions, deletions: file.deletions, isNew: file.status === 'A',
|
||||
}));
|
||||
}
|
||||
if (activeDiffScope === 'branch') {
|
||||
@@ -2033,7 +1964,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
if (commitFilesError) {
|
||||
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>
|
||||
<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>;
|
||||
}
|
||||
if (!selectedCommitHash && !commitComparison.loading) {
|
||||
@@ -2076,7 +2007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadBranchFiles()}
|
||||
onClick={() => void comparison.refresh()}
|
||||
>
|
||||
{t('diffView.actions.retry')}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
branchRangeKey,
|
||||
coerceDiffScope,
|
||||
isBranchScopeAvailable,
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -62,15 +62,6 @@ export const coerceDiffScope = <T extends string>(
|
||||
branchScopeAvailable: boolean
|
||||
): 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
|
||||
* 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 { 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' });
|
||||
if (mobile && !tablet) dom.happyDOM.setWindowSize({ width: 390, height: 844 });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
window: dom,
|
||||
@@ -38,6 +39,7 @@ test('allows repeated base selection with search and keyboard navigation', async
|
||||
function Harness() {
|
||||
const [base, setBase] = useState<string | null>(null);
|
||||
return <BranchComparisonSelector
|
||||
mobile={mobile}
|
||||
branches={['feature', 'main', 'parent', 'remotes/origin/main']}
|
||||
currentBranch="feature"
|
||||
base={base}
|
||||
@@ -60,6 +62,11 @@ test('allows repeated base selection with search and keyboard navigation', async
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
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();
|
||||
await press('ArrowDown');
|
||||
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 }));
|
||||
});
|
||||
expect(document.querySelector('input')).toBeNull();
|
||||
await act(async () => trigger().click());
|
||||
await press('Escape');
|
||||
expect(document.querySelector('input')).toBeNull();
|
||||
expect(choices).toHaveLength(3);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await dom.happyDOM.abort();
|
||||
@@ -103,4 +114,9 @@ test('allows repeated base selection with search and keyboard navigation', async
|
||||
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 { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { branchRefLabel } from './baseBranch';
|
||||
@@ -14,69 +16,89 @@ interface BranchComparisonSelectorProps {
|
||||
currentBranch: string | null;
|
||||
base: string | null;
|
||||
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 tabletLayout = useTabletLayout();
|
||||
const useSheet = mobile && !tabletLayout.enabled;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const label = base ? branchRefLabel(base) : t('gitView.pr.field.baseBranch');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setSearch('');
|
||||
const changeOpen = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setSearch('');
|
||||
};
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
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-haspopup={useSheet ? 'dialog' : undefined}
|
||||
aria-expanded={useSheet ? open : undefined}
|
||||
title={label}
|
||||
disabled={!currentBranch}
|
||||
onClick={useSheet ? () => changeOpen(true) : undefined}
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5" />
|
||||
<span className="truncate">{label}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
const picker = (
|
||||
<Command shouldFilter={false} className={mobile ? '[&_[data-slot=command-input-wrapper]]:h-11' : undefined} onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') event.stopPropagation();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
|
||||
aria-label={t('gitView.pr.field.baseBranch')}
|
||||
title={label}
|
||||
disabled={!currentBranch}
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5" />
|
||||
<span className="truncate">{label}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<CommandInput
|
||||
autoFocus={!useSheet}
|
||||
className={mobile ? 'h-11' : undefined}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
/>
|
||||
<CommandList className={mobile ? 'max-h-[min(45dvh,24rem)]' : undefined}>
|
||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{open && rankByQuery(
|
||||
[...new Set(branches)]
|
||||
.filter((name) => name !== currentBranch)
|
||||
.sort()
|
||||
.map((name) => ({
|
||||
ref: name.startsWith('remotes/') ? `refs/${name}` : `refs/heads/${name}`,
|
||||
label: branchRefLabel(name),
|
||||
})),
|
||||
search,
|
||||
(branch) => [branch.label],
|
||||
).map((branch) => (
|
||||
<CommandItem key={branch.ref} value={branch.ref} className={mobile ? 'min-h-11' : undefined} onSelect={() => {
|
||||
onSelect(branch.ref);
|
||||
changeOpen(false);
|
||||
}}>
|
||||
<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" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</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">
|
||||
<Command shouldFilter={false} onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') event.stopPropagation();
|
||||
}}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{open && rankByQuery(
|
||||
[...new Set(branches)]
|
||||
.filter((name) => name !== currentBranch)
|
||||
.sort()
|
||||
.map((name) => ({
|
||||
ref: name.startsWith('remotes/') ? `refs/${name}` : `refs/heads/${name}`,
|
||||
label: branchRefLabel(name),
|
||||
})),
|
||||
search,
|
||||
(branch) => [branch.label],
|
||||
).map((branch) => (
|
||||
<CommandItem key={branch.ref} value={branch.ref} onSelect={() => {
|
||||
onSelect(branch.ref);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}>
|
||||
<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" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{picker}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
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' });
|
||||
if (mobile && !tablet) 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,
|
||||
@@ -34,7 +35,7 @@ test('shows commit metadata and shares repeated searched selections between two
|
||||
function Harness() {
|
||||
const [hash, setHash] = useState<string | null>(null);
|
||||
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; }}
|
||||
onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} />
|
||||
</section>)}</>;
|
||||
@@ -55,6 +56,11 @@ test('shows commit metadata and shares repeated searched selections between two
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
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]');
|
||||
expect(first?.textContent).toContain('fix: first commit');
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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 { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -18,61 +20,84 @@ interface CommitComparisonSelectorProps {
|
||||
error: string | null;
|
||||
onSelect: (commit: GitLogEntry) => 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 tabletLayout = useTabletLayout();
|
||||
const useSheet = mobile && !tabletLayout.enabled;
|
||||
const timeFormat = useUIStore((state) => state.timeFormatPreference);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const changeOpen = (value: boolean) => {
|
||||
setOpen(value);
|
||||
if (!value) setSearch('');
|
||||
else if (!loading) onRefresh();
|
||||
};
|
||||
const trigger = (
|
||||
<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')}>
|
||||
<Icon name="git-commit" className="size-3.5" />
|
||||
<span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
const picker = (
|
||||
<Command shouldFilter={false} className={mobile ? '[&_[data-slot=command-input-wrapper]]:h-11' : undefined} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}>
|
||||
<CommandInput autoFocus={!useSheet} className={mobile ? 'h-11' : undefined} value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} />
|
||||
{loading ? (
|
||||
<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')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground">
|
||||
<span>{t('commitComparison.loadError')}</span>
|
||||
<span className="max-w-full break-words">{error}</span>
|
||||
<Button variant="outline" size={mobile ? 'lg' : 'sm'} onClick={onRefresh}>{t('diffView.actions.retry')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList className={mobile ? 'max-h-[min(45dvh,24rem)]' : undefined}>
|
||||
<CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => (
|
||||
<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="truncate typography-ui-label font-semibold" title={commit.message}>{commit.message}</div>
|
||||
<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, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span className="shrink-0">· {commit.hash.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{commit.hash === selectedHash && <Icon name="check" className="size-3.5" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
if (useSheet) {
|
||||
return <>
|
||||
{trigger}
|
||||
<MobileOverlayPanel open={open} title={t('commitComparison.select')} onClose={() => changeOpen(false)}>
|
||||
{picker}
|
||||
</MobileOverlayPanel>
|
||||
</>;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(value) => {
|
||||
setOpen(value);
|
||||
if (!value) setSearch('');
|
||||
else if (!loading) onRefresh();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
|
||||
aria-label={t('commitComparison.select')} title={selectedHash ?? t('commitComparison.select')}>
|
||||
<Icon name="git-commit" className="size-3.5" />
|
||||
<span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenu open={open} onOpenChange={changeOpen}>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[32rem] max-w-[calc(100vw-2rem)] p-0">
|
||||
<Command shouldFilter={false} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}>
|
||||
<CommandInput autoFocus value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} />
|
||||
{loading ? (
|
||||
<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')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground">
|
||||
<span>{t('commitComparison.loadError')}</span>
|
||||
<span className="max-w-full break-words">{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>{t('diffView.actions.retry')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{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(''); }}>
|
||||
<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-meta text-muted-foreground">
|
||||
{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',
|
||||
})} · {commit.hash.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
{commit.hash === selectedHash && <Icon name="check" className="size-3.5" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
{picker}
|
||||
</DropdownMenuContent>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 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 = (
|
||||
file: string,
|
||||
patch: string,
|
||||
|
||||
@@ -42,8 +42,8 @@ refresh attempt per opening, so a failed first load cannot create a retry loop.
|
||||
|
||||
### UI state stores
|
||||
|
||||
`useCommitSelectionStore.ts` shares the selected commit between Changes and
|
||||
walkthrough. Choices are session-only and keyed by runtime, directory, and
|
||||
`useCommitSelectionStore.ts` shares the selected commit between desktop/mobile
|
||||
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
|
||||
belongs to `useCommitComparison`, loads only while Commit mode is active, and
|
||||
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
|
||||
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:
|
||||
|
||||
- `useUIStore.ts`
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
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
|
||||
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
|
||||
|
||||
@@ -134,7 +134,7 @@ The following functions are internal helpers used by exported functions:
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user