fix: user can not resolve conflict in app (#9872)

* fix: conflict ux

* fix

* fix

(cherry picked from commit 133f633aeb)
This commit is contained in:
Curry Yang
2026-04-28 16:55:41 +08:00
committed by Insomnia
parent e4d5be760a
commit d5dfb054ae
8 changed files with 78 additions and 16 deletions

View File

@@ -196,7 +196,7 @@ const git: GitServiceAPI = {
pullFromGitRemote: options => invokeWithNormalizedError('git.pullFromGitRemote', options),
continueMerge: options => invokeWithNormalizedError('git.continueMerge', options),
discardChanges: options => invokeWithNormalizedError('git.discardChanges', options),
abortMerge: () => invokeWithNormalizedError('git.abortMerge'),
abortMerge: options => invokeWithNormalizedError('git.abortMerge', options),
gitStatus: options => invokeWithNormalizedError('git.gitStatus', options),
diff: () => invokeWithNormalizedError('git.diff'),
multipleCommitToGitRepo: options => invokeWithNormalizedError('git.multipleCommitToGitRepo', options),
@@ -265,7 +265,8 @@ const main: Window['main'] = {
requestId: string,
authentication: AuthTypeOAuth2,
forceRefresh?: boolean,
): Promise<OAuth2Token | undefined> => invokeWithNormalizedError('getOAuth2Token', requestId, authentication, forceRefresh),
): Promise<OAuth2Token | undefined> =>
invokeWithNormalizedError('getOAuth2Token', requestId, authentication, forceRefresh),
insecureReadFile: options => invokeWithNormalizedError('insecureReadFile', options),
insecureReadFileWithEncoding: options => invokeWithNormalizedError('insecureReadFileWithEncoding', options),
secureReadFile: options => invokeWithNormalizedError('secureReadFile', options),

View File

@@ -61,7 +61,7 @@ import GitVCS, {
import { MemClient } from '../sync/git/mem-client';
import { NeDBClient } from '../sync/git/ne-db-client';
import { projectRoutableFSClient } from '../sync/git/project-routable-fs-client';
import { repoFileWatcherRegistry } from '../sync/git/repo-file-watcher';
import { createElectronNotifier, RepoFileWatcherRegistry, type WatcherNotifier } from '../sync/git/repo-file-watcher';
import { routableFSClient } from '../sync/git/routable-fs-client';
import { shallowClone } from '../sync/git/shallow-clone';
import type { AutoResolvedConflict, MergeConflict } from '../sync/types';
@@ -72,6 +72,35 @@ import { ipcMainHandle } from './ipc/electron';
// Initialize Git Remote Providers on module load
initializeGitRemoteProviders();
/**
* Set of repo IDs for which conflict problems should be suppressed in the
* file-problems-changed IPC broadcast. Active while the user is resolving
* conflicts via SyncMergeModal so the generic "CLI conflict" blocking modal
* does not appear on top of the interactive resolver.
*/
const suppressedConflictRepos = new Set<string>();
const _electronNotifier = createElectronNotifier();
const conflictFilteringNotifier: WatcherNotifier = {
onDbSynced: () => _electronNotifier.onDbSynced(),
onProblemsChanged: payload => {
_electronNotifier.onProblemsChanged({
...payload,
conflictsSuppressed: suppressedConflictRepos.has(payload.repoId),
});
},
};
const repoFileWatcherRegistry = new RepoFileWatcherRegistry(conflictFilteringNotifier);
function suppressConflictProblems(repoId: string): void {
suppressedConflictRepos.add(repoId);
}
function clearConflictSuppression(repoId: string): void {
suppressedConflictRepos.delete(repoId);
}
type PushPull = 'push' | 'pull';
type VCSAction =
| PushPull
@@ -1543,6 +1572,7 @@ export const resetGitRepoAction = async ({ projectId, workspaceId }: { projectId
await services.gitRepository.remove(repo);
// Stop the file watcher for this repository (project-scoped flow only).
repoFileWatcherRegistry.stopWatcher(repo._id);
clearConflictSuppression(repo._id);
await database.flushChanges(flushId);
@@ -1982,6 +2012,7 @@ export const mergeGitBranch = async ({
const bufferId = await database.bufferChanges();
try {
suppressConflictProblems(gitRepository._id);
await GitVCS.merge({
theirsBranch,
allowUncommittedChangesBeforeMerge,
@@ -1993,6 +2024,7 @@ export const mergeGitBranch = async ({
// Import all YAML files from disk into the DB after merge + checkout
const gitRepoId = gitRepository._id;
await repoFileWatcherRegistry.importAllFiles(gitRepoId);
clearConflictSuppression(gitRepository._id);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'merge_branch'),
@@ -2013,8 +2045,10 @@ export const mergeGitBranch = async ({
return {};
} catch (err) {
if (err instanceof MergeConflictError) {
// Keep suppression active — user will resolve via SyncMergeModal.
return err.data;
}
clearConflictSuppression(gitRepository._id);
let errorMessage = getErrorMessage(err);
if (err instanceof Errors.HttpError) {
@@ -2231,9 +2265,12 @@ export async function fetchGitRemoteBranches({
}
export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) {
let repoId: string | null = null;
try {
await assertBranchOnOrigin('pull');
const gitRepository = await getGitRepository({ projectId, workspaceId });
repoId = gitRepository._id;
suppressConflictProblems(repoId);
invariant(gitRepository.credentialsId, 'Git Credentials ID is required');
const credentials = await services.gitCredentials.getById(gitRepository.credentialsId);
invariant(credentials, 'Git Credentials not found');
@@ -2243,6 +2280,7 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId:
// Import all YAML files from disk into the DB after pull
await repoFileWatcherRegistry.importAllFiles(gitRepository._id);
clearConflictSuppression(repoId);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'pull'),
@@ -2266,9 +2304,13 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId:
};
} catch (err: unknown) {
if (err instanceof MergeConflictError) {
// Keep suppression active — user will resolve via SyncMergeModal.
// clearConflictSuppression is called by continueMerge or abortMergeAction.
return err.data;
}
if (repoId) clearConflictSuppression(repoId);
if (
err instanceof Errors.UserCanceledError ||
(err instanceof Errors.HttpError && (err.data.statusCode === 401 || err.data.statusCode === 403))
@@ -2334,6 +2376,9 @@ export const continueMerge = async ({
// Import all YAML files from disk into the DB after merge resolution
await repoFileWatcherRegistry.importAllFiles(gitRepository._id);
// Files are clean now — lift the conflict suppression so any remaining
// issues (parse errors etc.) are reported normally.
clearConflictSuppression(gitRepository._id);
const log = (await GitVCS.log({ depth: 1 })) || [];
@@ -2414,7 +2459,9 @@ export const discardChangesAction = async ({
}
};
export const abortMergeAction = async () => {
export const abortMergeAction = async ({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) => {
const gitRepository = await getGitRepository({ projectId, workspaceId });
clearConflictSuppression(gitRepository._id);
return GitVCS.abortMerge();
};
@@ -2869,7 +2916,9 @@ export async function runAllGitRepoMigrations(): Promise<MigrationSummary> {
const ts = () => new Date().toISOString();
const projectList = gitProjects.map(p => `"${p.name}"`).join(', ');
logs.push(`${ts()} [INFO] Starting migration v${CURRENT_MIGRATION_VERSION} for ${gitProjects.length} repo(s): ${projectList}`);
logs.push(
`${ts()} [INFO] Starting migration v${CURRENT_MIGRATION_VERSION} for ${gitProjects.length} repo(s): ${projectList}`,
);
await Promise.all(
gitProjects.map(async project => {
@@ -3036,7 +3085,7 @@ export const registerGitServiceAPI = () => {
ipcMainHandle('git.discardChanges', (_, options: Parameters<typeof discardChangesAction>[0]) =>
discardChangesAction(options),
);
ipcMainHandle('git.abortMerge', _ => abortMergeAction());
ipcMainHandle('git.abortMerge', (_, options: Parameters<typeof abortMergeAction>[0]) => abortMergeAction(options));
ipcMainHandle('git.gitStatus', (_, options: Parameters<typeof gitStatusAction>[0]) => gitStatusAction(options));
ipcMainHandle('git.diff', () => diff());
ipcMainHandle('git.stageChanges', (_, options: Parameters<typeof stageChangesAction>[0]) =>

View File

@@ -401,7 +401,7 @@ const Component = () => {
projectId: string;
workspaceId: string;
};
const { issuesByWorkspaceId } = useGitFileIssues();
const { issuesByWorkspaceId, conflictsSuppressed } = useGitFileIssues();
const currentIssue = issuesByWorkspaceId[workspaceId];
const handleBackToList = () => {
@@ -414,7 +414,9 @@ const Component = () => {
};
const modalText = currentIssue ? workspaceFileIssueModalText[currentIssue.kind] : null;
const isIssueModalOpen = Boolean(currentIssue && modalText);
const isIssueModalOpen = Boolean(
currentIssue && modalText && !(currentIssue.kind === 'conflict' && conflictsSuppressed),
);
return (
<div className="h-full w-full overflow-hidden" data-testid="workspace-page">

View File

@@ -73,6 +73,8 @@ export interface FileProblemsChangedPayload {
repoId: string;
problems: FileIssue[];
workspaceIssues: WorkspaceFileIssue[];
/** True when the main process is suppressing conflict display (e.g. SyncMergeModal is open). */
conflictsSuppressed: boolean;
}
/** Compute a SHA-256 hex digest of a string. */
@@ -792,6 +794,7 @@ class RepoFileWatcher {
repoId: this.repoId,
problems: this.getProblems(),
workspaceIssues: this.getWorkspaceIssues(),
conflictsSuppressed: false,
});
}
}
@@ -900,7 +903,7 @@ export class RepoFileWatcherRegistry {
}
/** Default notifier that broadcasts to all Electron BrowserWindows. */
function createElectronNotifier(): WatcherNotifier {
export function createElectronNotifier(): WatcherNotifier {
return {
onDbSynced: () => {
for (const w of BrowserWindow.getAllWindows()) {
@@ -914,5 +917,3 @@ function createElectronNotifier(): WatcherNotifier {
},
};
}
export const repoFileWatcherRegistry = new RepoFileWatcherRegistry(createElectronNotifier());

View File

@@ -459,6 +459,7 @@ export const GitProjectSyncDropdown: FC<Props> = ({ gitRepository, activeProject
});
},
onCancelUnresolved: () => {
window.main.git.abortMerge({ projectId });
closeGitProjectStagingModalRef.current?.();
setIsPulling(false);
showToast({

View File

@@ -22,20 +22,24 @@ export const WorkspaceSyncDropdown: FC = () => {
}
const isLocalProject =
!models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId && !models.project.isGitProject(activeProject);
!models.project.isRemoteProject(activeProject) &&
!activeWorkspaceMeta?.gitRepositoryId &&
!models.project.isGitProject(activeProject);
if (isLocalProject) {
return <LocalProjectBar />;
}
const shouldShowCloudSyncDropdown = models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId;
const shouldShowCloudSyncDropdown =
models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId;
if (shouldShowCloudSyncDropdown) {
return <SyncDropdown key={activeWorkspace?._id} workspace={activeWorkspace} project={activeProject} />;
}
const shouldShowGitSyncDropdown =
features.gitSync.enabled && (activeWorkspaceMeta?.gitRepositoryId || !models.project.isRemoteProject(activeProject));
features.gitSync.enabled &&
(activeWorkspaceMeta?.gitRepositoryId || !models.project.isRemoteProject(activeProject));
if (shouldShowGitSyncDropdown) {
if (models.project.isGitProject(activeProject)) {
return (

View File

@@ -166,7 +166,7 @@ const LocalBranchItem = ({
},
onCancelUnresolved: () => {
// user aborted merge
window.main.git.abortMerge();
window.main.git.abortMerge({ projectId });
// TODO: the abortMerge method provided by isomorphic-git is unreliable
// clean up any partial merges here
reject(

View File

@@ -19,6 +19,7 @@ const mapIssuesByWorkspaceId = (issues: WorkspaceFileIssue[]) => {
export interface GitFileIssuesValue {
issuesByWorkspaceId: Record<string, WorkspaceFileIssue>;
conflictsSuppressed: boolean;
}
const GitFileIssuesContext = createContext<GitFileIssuesValue | undefined>(undefined);
@@ -43,6 +44,7 @@ export const useProjectGitFileIssues = ({
gitRepositoryId?: string | null;
}): GitFileIssuesValue => {
const [issuesByWorkspaceId, setIssuesByWorkspaceId] = useState<Record<string, WorkspaceFileIssue>>({});
const [conflictsSuppressed, setConflictsSuppressed] = useState(false);
const loadIssues = useCallback(async () => {
if (!projectId || !gitRepositoryId) {
@@ -76,6 +78,7 @@ export const useProjectGitFileIssues = ({
return;
}
setConflictsSuppressed(payload.conflictsSuppressed);
setIssuesByWorkspaceId(mapIssuesByWorkspaceId(payload.workspaceIssues));
});
}, [gitRepositoryId]);
@@ -83,7 +86,8 @@ export const useProjectGitFileIssues = ({
return useMemo<GitFileIssuesValue>(
() => ({
issuesByWorkspaceId,
conflictsSuppressed,
}),
[issuesByWorkspaceId],
[issuesByWorkspaceId, conflictsSuppressed],
);
};