Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR.
650 lines
27 KiB
TypeScript
650 lines
27 KiB
TypeScript
import React from 'react';
|
|
import { Icon } from '@/components/icon/Icon';
|
|
|
|
import { toast } from '@/components/ui';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
|
import { CommitSection } from '@/components/views/git/CommitSection';
|
|
import { SyncActions } from '@/components/views/git/SyncActions';
|
|
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import type { GitStatus } from '@/lib/api/types';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
|
|
import type { GitRemote } from '@/lib/gitApi';
|
|
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
|
import {
|
|
useGitStore,
|
|
useGitStatus,
|
|
useIsGitRepo,
|
|
useGitLoadingStatus,
|
|
} from '@/stores/useGitStore';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
|
|
|
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
|
|
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|
const indexStatus = file.index?.trim();
|
|
return Boolean(indexStatus && indexStatus !== '?');
|
|
};
|
|
|
|
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|
const workingStatus = file.working_dir?.trim();
|
|
const indexStatus = file.index?.trim();
|
|
return Boolean(workingStatus || indexStatus === '?');
|
|
};
|
|
|
|
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
|
|
|
|
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.
|
|
*/
|
|
initialDiffPath?: string | null;
|
|
initialDiffStaged?: boolean;
|
|
};
|
|
|
|
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
|
|
const { t } = useI18n();
|
|
const { git } = useRuntimeAPIs();
|
|
const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
|
|
const status = useGitStatus(currentDirectory || null);
|
|
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
|
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
|
const ensureAll = useGitStore((state) => state.ensureAll);
|
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
|
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
|
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' }),
|
|
);
|
|
|
|
// 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]);
|
|
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
|
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
|
const [commitMessage, setCommitMessage] = React.useState('');
|
|
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
|
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
|
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
|
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
|
|
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
|
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
|
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
|
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
|
|
|
const changeEntries = React.useMemo(() => {
|
|
const files = status?.files ?? [];
|
|
const unique = new Map<string, (typeof files)[number]>();
|
|
for (const file of files) {
|
|
unique.set(file.path, file);
|
|
}
|
|
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
|
}, [status?.files]);
|
|
|
|
const stagedChangeEntries = React.useMemo(
|
|
() => changeEntries.filter(isStagedStatusFile),
|
|
[changeEntries],
|
|
);
|
|
|
|
const unstagedChangeEntries = React.useMemo(
|
|
() => changeEntries.filter(isUnstagedStatusFile),
|
|
[changeEntries],
|
|
);
|
|
|
|
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
|
|
if (remotes.length > 0) return remotes;
|
|
const trackingRemote = status?.tracking?.includes('/') ? status.tracking.split('/')[0] : null;
|
|
if (trackingRemote || remoteUrl) {
|
|
return [{ name: trackingRemote || 'origin', fetchUrl: remoteUrl ?? '', pushUrl: remoteUrl ?? '' }];
|
|
}
|
|
return [];
|
|
}, [remoteUrl, remotes, status?.tracking]);
|
|
|
|
const selectedDiff = useGitStore(React.useCallback((state) => {
|
|
if (!currentDirectory || route.type !== 'diff') return null;
|
|
return state.directories.get(currentDirectory)?.diffCache.get(diffCacheKey(route.path, route.staged)) ?? null;
|
|
}, [currentDirectory, route]));
|
|
|
|
const selectedFileEntry = React.useMemo(() => {
|
|
if (route.type !== 'diff') return null;
|
|
return changeEntries.find((entry) => entry.path === route.path) ?? null;
|
|
}, [changeEntries, route]);
|
|
|
|
const refreshStatusAndBranches = React.useCallback(async (showErrors = true) => {
|
|
if (!currentDirectory) return;
|
|
try {
|
|
await Promise.all([
|
|
fetchStatus(currentDirectory, git),
|
|
fetchBranches(currentDirectory, git),
|
|
]);
|
|
} catch (error) {
|
|
if (showErrors) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.refreshRepositoryFailed'));
|
|
}
|
|
}
|
|
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
|
|
|
const refreshRemotes = React.useCallback(async () => {
|
|
if (!currentDirectory) {
|
|
setRemotes([]);
|
|
setRemoteUrl(null);
|
|
return;
|
|
}
|
|
try {
|
|
const [remoteList, url] = await Promise.all([
|
|
git.getRemotes(currentDirectory).catch(() => []),
|
|
git.getRemoteUrl ? git.getRemoteUrl(currentDirectory).catch(() => null) : Promise.resolve(null),
|
|
]);
|
|
setRemotes(remoteList);
|
|
setRemoteUrl(url);
|
|
} catch {
|
|
setRemotes([]);
|
|
setRemoteUrl(null);
|
|
}
|
|
}, [currentDirectory, git]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory) return;
|
|
setActiveDirectory(currentDirectory);
|
|
void ensureAll(currentDirectory, git);
|
|
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
void refreshRemotes();
|
|
}, [refreshRemotes]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory || changeEntries.length === 0) return;
|
|
const orderedPaths = Array.from(new Set([
|
|
...stagedChangeEntries.map((entry) => entry.path),
|
|
...visibleChangePaths,
|
|
...changeEntries.slice(0, 20).map((entry) => entry.path),
|
|
])).filter(Boolean);
|
|
if (orderedPaths.length === 0) return;
|
|
const timeoutId = window.setTimeout(() => {
|
|
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
|
|
}, 120);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
|
|
|
React.useEffect(() => {
|
|
if (route.type !== 'diff') {
|
|
setDiffLoadError(null);
|
|
return;
|
|
}
|
|
const cacheKey = diffCacheKey(route.path, route.staged);
|
|
if (!currentDirectory || getDiff(currentDirectory, cacheKey)) {
|
|
setDiffLoadError(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const runtimeKey = getRuntimeKey();
|
|
setDiffLoadError(null);
|
|
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
setDiff(currentDirectory, cacheKey, {
|
|
original: response.original ?? '',
|
|
modified: response.modified ?? '',
|
|
isBinary: response.isBinary,
|
|
}, runtimeKey);
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
setDiffLoadError(error instanceof Error ? error.message : String(error));
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
|
|
|
|
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
|
if (!currentDirectory) return;
|
|
setSyncAction(action);
|
|
try {
|
|
const getPullOptions = (pullRemote: GitRemote) => {
|
|
const trackingPrefix = `${pullRemote.name}/`;
|
|
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
|
? status.tracking.slice(trackingPrefix.length)
|
|
: undefined;
|
|
return { remote: pullRemote.name, branch: trackedBranch, rebase: true };
|
|
};
|
|
|
|
if (action === 'fetch') {
|
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
|
|
} else if (action === 'sync') {
|
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
const afterFetch = await git.getGitStatus(currentDirectory);
|
|
if ((afterFetch.behind ?? 0) > 0) {
|
|
if ((afterFetch.files?.length ?? 0) > 0) {
|
|
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
|
return;
|
|
}
|
|
await git.gitPull(currentDirectory, getPullOptions(remote));
|
|
}
|
|
const afterPull = await git.getGitStatus(currentDirectory);
|
|
if ((afterPull.ahead ?? 0) > 0) {
|
|
await git.gitPush(currentDirectory);
|
|
}
|
|
toast.success(t('gitView.toast.alreadyUpToDate'));
|
|
}
|
|
await refreshStatusAndBranches(false);
|
|
await refreshRemotes();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.syncActionFailed', { action: t('gitView.sync.syncChanges') }));
|
|
} finally {
|
|
setSyncAction(null);
|
|
}
|
|
};
|
|
|
|
const moveChangePaths = React.useCallback(async (paths: string[], direction: 'stage' | 'unstage') => {
|
|
if (!currentDirectory || paths.length === 0) return;
|
|
try {
|
|
if (direction === 'stage') {
|
|
if (paths.length > 1) await stageGitFiles(currentDirectory, paths);
|
|
else await stageGitFile(currentDirectory, paths[0]);
|
|
} else {
|
|
if (paths.length > 1) await unstageGitFiles(currentDirectory, paths);
|
|
else await unstageGitFile(currentDirectory, paths[0]);
|
|
}
|
|
await refreshStatusAndBranches(false);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : direction === 'stage'
|
|
? t('gitView.toast.stageFileFailed')
|
|
: t('gitView.toast.unstageFileFailed'));
|
|
}
|
|
}, [currentDirectory, refreshStatusAndBranches, t]);
|
|
|
|
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
|
|
setRoute({ type: 'diff', path, staged });
|
|
}, []);
|
|
|
|
const handleRevertFile = React.useCallback(async (filePath: string) => {
|
|
if (!currentDirectory) return;
|
|
setRevertingPaths((previous) => new Set(previous).add(filePath));
|
|
try {
|
|
await git.revertGitFile(currentDirectory, filePath);
|
|
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
|
await refreshStatusAndBranches(false);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
|
} finally {
|
|
setRevertingPaths((previous) => {
|
|
const next = new Set(previous);
|
|
next.delete(filePath);
|
|
return next;
|
|
});
|
|
}
|
|
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
|
|
|
const handleRevertAll = React.useCallback(async (paths: string[]) => {
|
|
if (!currentDirectory || paths.length === 0 || isRevertingAll) return;
|
|
const uniquePaths = Array.from(new Set(paths));
|
|
setIsRevertingAll(true);
|
|
setRevertingPaths(new Set(uniquePaths));
|
|
try {
|
|
await Promise.all(uniquePaths.map((filePath) => git.revertGitFile(currentDirectory, filePath)));
|
|
await refreshStatusAndBranches(false);
|
|
toast.success(uniquePaths.length === 1
|
|
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
|
|
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length }));
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
|
} finally {
|
|
setRevertingPaths(new Set());
|
|
setIsRevertingAll(false);
|
|
}
|
|
}, [currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t]);
|
|
|
|
const handleInsertHighlights = React.useCallback((highlights: string[]) => {
|
|
const normalized = highlights.map((text) => text.trim()).filter(Boolean);
|
|
if (normalized.length === 0) {
|
|
setGeneratedHighlights([]);
|
|
return;
|
|
}
|
|
setCommitMessage((current) => `${current.trim()}${current.trim() ? '\n\n' : ''}${normalized.join('\n')}`.trim());
|
|
setGeneratedHighlights([]);
|
|
}, []);
|
|
|
|
const handleGenerateCommitMessage = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
|
|
if (selectedFilePaths.length === 0) {
|
|
toast.error(t('gitView.toast.selectFileToDescribe'));
|
|
return;
|
|
}
|
|
setIsGeneratingMessage(true);
|
|
try {
|
|
const { message } = await generateCommitMessage(currentDirectory, selectedFilePaths);
|
|
setCommitMessage(message.subject?.trim() ?? '');
|
|
setGeneratedHighlights(Array.isArray(message.highlights) ? message.highlights : []);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed'));
|
|
} finally {
|
|
setIsGeneratingMessage(false);
|
|
}
|
|
}, [currentDirectory, stagedChangeEntries, t]);
|
|
|
|
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
|
if (!currentDirectory) return;
|
|
if (!commitMessage.trim()) {
|
|
toast.error(t('gitView.toast.enterCommitMessage'));
|
|
return;
|
|
}
|
|
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
|
|
if (filesToCommit.length === 0) {
|
|
toast.error(t('gitView.toast.selectFileToCommit'));
|
|
return;
|
|
}
|
|
|
|
setCommitAction(options.pushAfter ? 'commitAndPush' : 'commit');
|
|
try {
|
|
await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit });
|
|
toast.success(t('gitView.toast.commitCreated'));
|
|
setCommitMessage('');
|
|
setGeneratedHighlights([]);
|
|
|
|
if (options.pushAfter) {
|
|
const trackingRemoteName = status?.tracking?.split('/')[0];
|
|
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
|
setSyncAction('sync');
|
|
const trackingPrefix = `${remote.name}/`;
|
|
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
|
? status.tracking.slice(trackingPrefix.length)
|
|
: undefined;
|
|
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
const afterFetch = await git.getGitStatus(currentDirectory);
|
|
if ((afterFetch.behind ?? 0) > 0) {
|
|
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
|
|
}
|
|
|
|
const afterPull = await git.getGitStatus(currentDirectory);
|
|
if ((afterPull.ahead ?? 0) > 0) {
|
|
await git.gitPush(currentDirectory);
|
|
}
|
|
|
|
await refreshStatusAndBranches(false);
|
|
await refreshRemotes();
|
|
} else {
|
|
await refreshStatusAndBranches(false);
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.createCommitFailed'));
|
|
} finally {
|
|
setCommitAction(null);
|
|
if (options.pushAfter) setSyncAction(null);
|
|
}
|
|
};
|
|
|
|
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
|
|
const groups: ChangesGroupConfig[] = [];
|
|
|
|
if (stagedChangeEntries.length > 0) {
|
|
groups.push({
|
|
id: 'staged',
|
|
title: t('gitView.changes.stagedTitle'),
|
|
entries: stagedChangeEntries,
|
|
actionSymbol: '-',
|
|
actionAllLabel: t('gitView.changes.unstageAllAria'),
|
|
getActionLabel: (path: string) => t('gitView.changes.unstageFileAria', { path }),
|
|
onActionFile: (path: string) => void moveChangePaths([path], 'unstage'),
|
|
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'unstage'),
|
|
onViewDiff: (path: string) => handleViewChangeDiff(path, true),
|
|
onRevertFile: handleRevertFile,
|
|
showRevertActions: false,
|
|
accent: true,
|
|
});
|
|
}
|
|
|
|
if (unstagedChangeEntries.length > 0) {
|
|
groups.push({
|
|
id: 'unstaged',
|
|
title: t('gitView.changes.title'),
|
|
entries: unstagedChangeEntries,
|
|
actionSymbol: '+',
|
|
actionAllLabel: t('gitView.changes.stageAllAria'),
|
|
getActionLabel: (path: string) => t('gitView.changes.stageFileAria', { path }),
|
|
onActionFile: (path: string) => void moveChangePaths([path], 'stage'),
|
|
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'stage'),
|
|
onViewDiff: (path: string) => handleViewChangeDiff(path, false),
|
|
onRevertFile: handleRevertFile,
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}, [handleRevertFile, handleViewChangeDiff, moveChangePaths, stagedChangeEntries, t, unstagedChangeEntries]);
|
|
|
|
const renderListState = (state: React.ReactNode) => (
|
|
<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">
|
|
{onClose ? (
|
|
<button
|
|
type="button"
|
|
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
aria-label={t('mobile.surface.closeAria')}
|
|
onClick={onClose}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<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>
|
|
<p className="truncate typography-micro text-muted-foreground">
|
|
{status?.current || currentDirectory || ''}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
<div className="min-h-0 flex-1">{state}</div>
|
|
</div>
|
|
);
|
|
|
|
if (!currentDirectory) {
|
|
return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />);
|
|
}
|
|
|
|
if (isLoadingStatus && isGitRepo === null) {
|
|
return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />);
|
|
}
|
|
|
|
if (isGitRepo === false) {
|
|
return renderListState(<MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />);
|
|
}
|
|
|
|
if (route.type === 'diff') {
|
|
return (
|
|
<MobileDiffDetail
|
|
path={route.path}
|
|
diff={selectedDiff}
|
|
fileExists={Boolean(selectedFileEntry)}
|
|
error={diffLoadError}
|
|
onBack={() => setRoute({ type: 'list' })}
|
|
onRetry={() => setDiffRetryNonce((value) => value + 1)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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">
|
|
{onClose ? (
|
|
<button
|
|
type="button"
|
|
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
aria-label={t('mobile.surface.closeAria')}
|
|
onClick={onClose}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<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>
|
|
<p className="truncate typography-micro text-muted-foreground">
|
|
{status?.current || currentDirectory}
|
|
</p>
|
|
</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 ? (
|
|
<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">
|
|
<ChangesPanel
|
|
groups={changeGroups}
|
|
diffStats={status?.diffStats}
|
|
revertingPaths={revertingPaths}
|
|
onRevertAll={handleRevertAll}
|
|
isRevertingAll={isRevertingAll}
|
|
headerBackgroundClassName="bg-transparent"
|
|
onVisiblePathsChange={setVisibleChangePaths}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0 border-t border-border/70 px-4 pb-4 pt-3">
|
|
<CommitSection
|
|
stagedCount={stagedChangeEntries.length}
|
|
commitMessage={commitMessage}
|
|
onCommitMessageChange={setCommitMessage}
|
|
generatedHighlights={generatedHighlights}
|
|
onInsertHighlights={handleInsertHighlights}
|
|
onGenerateMessage={handleGenerateCommitMessage}
|
|
isGeneratingMessage={isGeneratingMessage}
|
|
onCommit={() => void handleCommit({ pushAfter: false })}
|
|
onCommitAndPush={() => void handleCommit({ pushAfter: true })}
|
|
commitAction={commitAction}
|
|
gitmojiEnabled={false}
|
|
onOpenGitmojiPicker={() => {}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1">
|
|
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MobileChangesState: React.FC<{
|
|
message: string;
|
|
description?: string;
|
|
loading?: boolean;
|
|
icon?: boolean;
|
|
}> = ({ message, description, loading = false, icon = false }) => (
|
|
<div className="flex h-full items-center justify-center px-6 text-center">
|
|
<div className="flex max-w-sm flex-col items-center gap-2">
|
|
{loading ? <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" /> : null}
|
|
{icon ? <Icon name="git-branch" className="size-6 text-muted-foreground" /> : null}
|
|
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
|
|
{description ? <p className="typography-meta text-muted-foreground">{description}</p> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const MobileDiffDetail: React.FC<{
|
|
path: string;
|
|
diff: { original: string; modified: string; isBinary?: boolean } | null;
|
|
fileExists: boolean;
|
|
error: string | null;
|
|
onBack: () => void;
|
|
onRetry: () => void;
|
|
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
|
|
const { t } = useI18n();
|
|
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
|
|
|
|
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-3 border-b border-border/70 px-3 text-foreground">
|
|
<button
|
|
type="button"
|
|
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
aria-label={t('header.actions.backAria')}
|
|
onClick={onBack}
|
|
>
|
|
<Icon name="arrow-left" className="size-5" />
|
|
</button>
|
|
<div className="min-w-0 flex-1 px-2">
|
|
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
|
|
</div>
|
|
</header>
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
{!fileExists ? (
|
|
<MobileChangesState icon message={t('mobile.changes.diffDetail.missingTitle')} description={t('mobile.changes.diffDetail.missingDescription')} />
|
|
) : error ? (
|
|
<div className="flex h-full items-center justify-center px-6 text-center">
|
|
<div className="flex max-w-sm flex-col items-center gap-3">
|
|
<p className="typography-ui-label font-semibold text-foreground">{t('mobile.changes.diffDetail.loadFailed')}</p>
|
|
<p className="typography-meta text-muted-foreground">{error}</p>
|
|
<Button type="button" size="sm" variant="outline" onClick={onRetry}>{t('diffView.actions.retry')}</Button>
|
|
</div>
|
|
</div>
|
|
) : !diff ? (
|
|
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
|
|
) : diff.isBinary ? (
|
|
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
|
|
) : isImageFile(path) ? (
|
|
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
|
) : (
|
|
<ScrollShadow
|
|
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
|
data-diff-virtual-root
|
|
data-diff-virtual-content
|
|
>
|
|
<PierreDiffViewer
|
|
original={diff.original}
|
|
modified={diff.modified}
|
|
language={language}
|
|
fileName={path}
|
|
renderSideBySide={false}
|
|
wrapLines={true}
|
|
layout="inline"
|
|
/>
|
|
</ScrollShadow>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|