2026-06-02 00:43:05 +03:00
|
|
|
import React from 'react';
|
2026-06-12 02:51:01 +08:00
|
|
|
import { Icon } from '@/components/icon/Icon';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
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';
|
2026-09-03 01:42:50 +03:00
|
|
|
import { BranchSelector } from '@/components/views/git/BranchSelector';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { CommitSection } from '@/components/views/git/CommitSection';
|
2026-09-03 01:42:50 +03:00
|
|
|
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { SyncActions } from '@/components/views/git/SyncActions';
|
|
|
|
|
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
|
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
2026-08-25 19:15:53 +05:30
|
|
|
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
|
2026-06-02 00:43:05 +03:00
|
|
|
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,
|
2026-09-03 01:42:50 +03:00
|
|
|
useGitBranches,
|
2026-06-02 00:43:05 +03:00
|
|
|
useIsGitRepo,
|
|
|
|
|
useGitLoadingStatus,
|
|
|
|
|
} from '@/stores/useGitStore';
|
2026-08-25 19:15:53 +05:30
|
|
|
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
|
2026-08-25 20:47:02 +05:30
|
|
|
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
|
2026-07-21 20:52:20 +03:00
|
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
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 = {
|
2026-08-01 21:16:36 +03:00
|
|
|
/** When provided, the list header gets a close X that calls this. */
|
2026-06-02 00:43:05 +03:00
|
|
|
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();
|
2026-08-25 19:15:53 +05:30
|
|
|
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 currentDirectory = gitDirectory ?? rootDirectory;
|
2026-06-02 00:43:05 +03:00
|
|
|
const status = useGitStatus(currentDirectory || null);
|
2026-09-03 01:42:50 +03:00
|
|
|
const branches = useGitBranches(currentDirectory || null);
|
2026-06-02 00:43:05 +03:00
|
|
|
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
|
|
|
|
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
|
|
|
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
|
|
|
|
const ensureAll = useGitStore((state) => state.ensureAll);
|
2026-08-25 19:15:53 +05:30
|
|
|
const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
|
2026-08-25 20:47:02 +05:30
|
|
|
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
|
2026-06-02 00:43:05 +03:00
|
|
|
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);
|
2026-09-03 01:42:50 +03:00
|
|
|
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-09-03 01:42:50 +03:00
|
|
|
const localBranches = React.useMemo(
|
|
|
|
|
() => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(),
|
|
|
|
|
[branches],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const remoteBranches = React.useMemo(
|
|
|
|
|
() => (branches?.all ?? [])
|
|
|
|
|
.filter((branch) => branch.startsWith('remotes/'))
|
|
|
|
|
.map((branch) => branch.replace(/^remotes\//, ''))
|
|
|
|
|
.sort(),
|
|
|
|
|
[branches],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const performCheckout = React.useCallback(async (branch: string) => {
|
|
|
|
|
if (!currentDirectory) return;
|
|
|
|
|
const normalized = branch.replace(/^remotes\//, '');
|
|
|
|
|
try {
|
|
|
|
|
const result = await git.checkoutBranch(currentDirectory, normalized);
|
|
|
|
|
toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized }));
|
|
|
|
|
await refreshStatusAndBranches();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized }));
|
|
|
|
|
}
|
|
|
|
|
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
|
|
|
|
|
|
|
|
|
const handleCheckoutBranch = React.useCallback((branch: string) => {
|
|
|
|
|
const normalized = branch.replace(/^remotes\//, '');
|
|
|
|
|
if ((status?.files?.length ?? 0) > 0) {
|
|
|
|
|
setPendingDirtySwitchBranch(normalized);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void performCheckout(normalized);
|
|
|
|
|
}, [performCheckout, status?.files]);
|
|
|
|
|
|
|
|
|
|
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
|
|
|
|
|
if (!currentDirectory) return;
|
|
|
|
|
try {
|
|
|
|
|
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
|
|
|
|
|
await git.checkoutBranch(currentDirectory, branch);
|
|
|
|
|
if (remote) {
|
|
|
|
|
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
|
|
|
|
|
}
|
|
|
|
|
await refreshStatusAndBranches();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
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;
|
2026-07-21 20:52:20 +03:00
|
|
|
const runtimeKey = getRuntimeKey();
|
2026-06-02 00:43:05 +03:00
|
|
|
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,
|
2026-07-21 20:52:20 +03:00
|
|
|
}, runtimeKey);
|
2026-06-02 00:43:05 +03:00
|
|
|
})
|
|
|
|
|
.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]);
|
|
|
|
|
|
2026-06-12 02:51:01 +08:00
|
|
|
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>
|
2026-08-25 20:47:02 +05:30
|
|
|
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
|
|
|
|
|
<NestedRepoPicker
|
|
|
|
|
repositories={nestedRepos}
|
|
|
|
|
selectedRepository={gitDirectory ?? null}
|
|
|
|
|
onSelectRepository={(repository) => {
|
|
|
|
|
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
|
|
|
|
|
}}
|
|
|
|
|
repositoryRoot={rootDirectory ?? undefined}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-06-12 02:51:01 +08:00
|
|
|
</header>
|
|
|
|
|
<div className="min-h-0 flex-1">{state}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
if (!currentDirectory) {
|
2026-06-12 02:51:01 +08:00
|
|
|
return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />);
|
2026-06-02 00:43:05 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 20:47:02 +05:30
|
|
|
// Non-repo root: surface nested-repository resolution while the operating
|
|
|
|
|
// directory has not proven to be a repository (discovering, failed,
|
|
|
|
|
// unsupported, none found, or settling on the auto-selected one).
|
|
|
|
|
if (rootIsGitRepo === false && isGitRepo !== true) {
|
2026-08-25 19:15:53 +05:30
|
|
|
return renderListState(
|
|
|
|
|
<NestedRepoResolutionStates
|
|
|
|
|
rootIsGitRepo={rootIsGitRepo}
|
2026-08-25 19:55:00 +05:30
|
|
|
resolvedIsGitRepo={isGitRepo}
|
2026-08-25 19:15:53 +05:30
|
|
|
nestedRepos={nestedRepos}
|
|
|
|
|
onRetryDiscovery={() => {
|
|
|
|
|
if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-06-02 00:43:05 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 19:15:53 +05:30
|
|
|
if (isLoadingStatus && isGitRepo === null) {
|
|
|
|
|
return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />);
|
2026-06-02 00:43:05 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' }}
|
|
|
|
|
>
|
2026-06-12 02:51:01 +08:00
|
|
|
<Icon name="close" className="size-5" />
|
2026-06-02 00:43:05 +03:00
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="min-w-0 flex-1 px-1">
|
|
|
|
|
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
2026-09-03 01:42:50 +03:00
|
|
|
<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}
|
|
|
|
|
/>
|
2026-06-02 00:43:05 +03:00
|
|
|
</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>
|
2026-06-02 16:46:19 +03:00
|
|
|
{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">
|
2026-06-02 00:43:05 +03:00
|
|
|
<ChangesPanel
|
|
|
|
|
groups={changeGroups}
|
|
|
|
|
diffStats={status?.diffStats}
|
|
|
|
|
revertingPaths={revertingPaths}
|
|
|
|
|
onRevertAll={handleRevertAll}
|
|
|
|
|
isRevertingAll={isRevertingAll}
|
|
|
|
|
headerBackgroundClassName="bg-transparent"
|
|
|
|
|
onVisiblePathsChange={setVisibleChangePaths}
|
|
|
|
|
/>
|
2026-06-02 16:46:19 +03:00
|
|
|
</div>
|
2026-08-01 21:16:36 +03:00
|
|
|
<div className="shrink-0 border-t border-border/70 px-4 pb-4 pt-3">
|
2026-06-02 00:43:05 +03:00
|
|
|
<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>
|
2026-06-02 16:46:19 +03:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="min-h-0 flex-1">
|
2026-06-02 00:43:05 +03:00
|
|
|
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
2026-06-02 16:46:19 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-09-03 01:42:50 +03:00
|
|
|
<DirtyBranchSwitchDialog
|
|
|
|
|
open={pendingDirtySwitchBranch !== null}
|
|
|
|
|
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
|
|
|
|
|
targetBranch={pendingDirtySwitchBranch ?? ''}
|
|
|
|
|
changedFileCount={status?.files?.length ?? 0}
|
|
|
|
|
onCommitAndSwitch={async (message, pushAfter) => {
|
|
|
|
|
const branch = pendingDirtySwitchBranch;
|
|
|
|
|
if (!branch || !currentDirectory) return;
|
|
|
|
|
const sourceBranch = status?.current ?? null;
|
|
|
|
|
await git.createGitCommit(currentDirectory, message, { addAll: true });
|
|
|
|
|
let pushedRemoteName: string | null = null;
|
|
|
|
|
if (pushAfter) {
|
|
|
|
|
const trackingRemoteName = status?.tracking?.split('/')[0];
|
|
|
|
|
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
|
|
|
|
try {
|
|
|
|
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
|
|
|
|
await git.gitPush(currentDirectory, status?.tracking
|
|
|
|
|
? { remote: remote.name }
|
|
|
|
|
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
|
|
|
|
|
pushedRemoteName = remote.name;
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error(t('gitView.dirtySwitch.pushFailed'));
|
|
|
|
|
await refreshStatusAndBranches();
|
|
|
|
|
setPendingDirtySwitchBranch(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
toast.success(sourceBranch
|
|
|
|
|
? pushedRemoteName
|
|
|
|
|
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
|
|
|
|
|
: t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
|
|
|
|
|
: t('gitView.toast.commitCreated'));
|
|
|
|
|
await refreshStatusAndBranches();
|
|
|
|
|
setPendingDirtySwitchBranch(null);
|
|
|
|
|
await performCheckout(branch);
|
|
|
|
|
}}
|
|
|
|
|
onGenerateMessage={async () => {
|
|
|
|
|
if (!currentDirectory) return '';
|
|
|
|
|
const paths = (status?.files ?? []).map((file) => file.path).sort();
|
|
|
|
|
const { message } = await generateCommitMessage(currentDirectory, paths);
|
|
|
|
|
return message.subject?.trim() ?? '';
|
|
|
|
|
}}
|
|
|
|
|
onRevertAndSwitch={async () => {
|
|
|
|
|
const branch = pendingDirtySwitchBranch;
|
|
|
|
|
if (!branch || !currentDirectory) return;
|
|
|
|
|
await handleRevertAll((status?.files ?? []).map((file) => file.path));
|
|
|
|
|
const fresh = await git.getGitStatus(currentDirectory);
|
|
|
|
|
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
|
|
|
|
|
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setPendingDirtySwitchBranch(null);
|
|
|
|
|
await performCheckout(branch);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-06-02 00:43:05 +03:00
|
|
|
</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">
|
2026-06-12 02:51:01 +08:00
|
|
|
{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}
|
2026-06-02 00:43:05 +03:00
|
|
|
<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">
|
2026-08-01 21:16:36 +03:00
|
|
|
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/70 px-3 text-foreground">
|
2026-06-02 00:43:05 +03:00
|
|
|
<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}
|
|
|
|
|
>
|
2026-06-12 02:51:01 +08:00
|
|
|
<Icon name="arrow-left" className="size-5" />
|
2026-06-02 00:43:05 +03:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
};
|