Skip to content

Commit 092cef5

Browse files
committed
Keep "Changed files" current after the app changes the tree
Two findings from the review of the #669 stack: - The contributor's file list stayed as it was after a discard, a ticket switch, or an apply, revert or pull request switch, until the window next regained focus. Each of those now clears the list and asks again. A journey discards a new file and checks it leaves "Your changes" without a refocus; the "list goes away" checks wait for no dialog to be open, since a modal makes an absence check pass on its own. - Moving a ticket onto trunk dropped the applied patch's text but kept its fingerprints, so a file trunk changed could be marked "also edited". The move now drops both. Refs #669
1 parent f2d3ec7 commit 092cef5

5 files changed

Lines changed: 132 additions & 28 deletions

File tree

‎src/main.js‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3527,9 +3527,16 @@ ipcMain.handle('branches:rebase', async (event, sitePath) => withRegisteredSite(
35273527
// resolving the scope is a Git spawn, and an apply or a discard landing
35283528
// in that window used to be replaced by this record, leaving a revert
35293529
// banner for a patch that is not there (#172).
3530-
await changeWorkMetaOn(sitePath, ref, (work) => (work.appliedPatch && work.appliedPatch.text
3531-
? { appliedPatch: { ...work.appliedPatch, text: null } }
3532-
: null));
3530+
//
3531+
// Its fingerprints go too (#669): the merge brings trunk's changes into
3532+
// files the patch wrote, and a file trunk changed is not one the
3533+
// contributor edited. Without them the record marks nothing, like one
3534+
// from before they were kept.
3535+
await changeWorkMetaOn(sitePath, ref, (work) => {
3536+
if (!work.appliedPatch || !(work.appliedPatch.text || work.appliedPatch.fingerprints)) return null;
3537+
const { fingerprints: _dropped, ...kept } = work.appliedPatch;
3538+
return { appliedPatch: { ...kept, text: null } };
3539+
});
35333540
}
35343541
return { ok: true, ticket: ticketIdFromRef(ref), from: result.from, to: result.to, rebased: result.rebased, parked: result.parked };
35353542
}));

‎src/renderer/index.jsx‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,17 +1009,6 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
10091009
// before it, so a stale "dirty" cannot resurrect the note over a tree that
10101010
// was just reset.
10111011
const dirtyProbeRef = useRef({ inFlight: false, generation: 0, again: false });
1012-
// The local answer a discard supplies (#239) — what survived the reset,
1013-
// decided by the module so the card has one rule for it and a test to hold
1014-
// it. The generation bump is what makes it outrank a probe that started
1015-
// before the discard did.
1016-
// Memoised with nothing to depend on: it touches a ref and a setter. The
1017-
// ticket hook lists it among a callback's dependencies, and a new function
1018-
// here on every render would give that callback a new identity each time.
1019-
const applyDiscardToNote = useCallback((outcome) => {
1020-
dirtyProbeRef.current.generation++;
1021-
setWorktreeDirty(noteAfterDiscard(outcome));
1022-
}, []);
10231012
const refreshDirty = useCallback(async () => {
10241013
const probe = dirtyProbeRef.current;
10251014
// A walk already running answers for the tree as it was when it started.
@@ -1047,6 +1036,30 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
10471036
} finally { probe.inFlight = false; }
10481037
}, [sitePath]);
10491038

1039+
// The file list under "Changed files" (#669) has no local answer to take
1040+
// from a discard: what a discard leaves is a list only a walk can give. So
1041+
// it is forgotten, and walked again, wherever the app itself changes the
1042+
// tree. A list kept until the next focus would go on naming files that are
1043+
// gone, or call a reverted patch's files the contributor's.
1044+
const forgetChangedFiles = useCallback(() => {
1045+
dirtyProbeRef.current.generation++;
1046+
setUnsubmitted(null);
1047+
refreshDirty();
1048+
}, [refreshDirty]);
1049+
// The local answer a discard supplies (#239) — what survived the reset,
1050+
// decided by the module so the card has one rule for it and a test to hold
1051+
// it. The generation bump in `forgetChangedFiles` is what makes it outrank a
1052+
// probe that started before the discard did; the walk it starts then answers
1053+
// for the list, and for the note with it.
1054+
// Memoised on `forgetChangedFiles` alone, which changes only with the site:
1055+
// the ticket hook lists it among a callback's dependencies, and a new
1056+
// function here on every render would give that callback a new identity
1057+
// each time.
1058+
const applyDiscardToNote = useCallback((outcome) => {
1059+
forgetChangedFiles();
1060+
setWorktreeDirty(noteAfterDiscard(outcome));
1061+
}, [forgetChangedFiles]);
1062+
10501063
// A branch change invalidates the note outright rather than staling it: the
10511064
// count is measured from the ticket's branch point (#239), so the previous
10521065
// ticket's answer is not an old version of this one's, it is about a
@@ -1055,10 +1068,9 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
10551068
// discard link — so the note is cleared while the new walk runs, and the
10561069
// generation bump keeps the outgoing branch's answer from landing on it.
10571070
const reprobeAfterBranchChange = useCallback(() => {
1058-
dirtyProbeRef.current.generation++;
10591071
setWorktreeDirty(null);
1060-
refreshDirty();
1061-
}, [refreshDirty]);
1072+
forgetChangedFiles();
1073+
}, [forgetChangedFiles]);
10621074

10631075
// Only the open card probes, and only while it is open: the probe walks the
10641076
// whole checkout, which is too much to pay for every card on the shelf. The
@@ -1366,7 +1378,10 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
13661378
// ticket offers, the preview, and the chain. Called here because it runs
13671379
// through everything above, and because what follows reads whether an
13681380
// apply is under way.
1369-
const { applyState, isApplying, applyKind, applySteps, applyStepStates, applyPreview, setApplyPreview, applyError, setApplyError, applyConflict, setApplyConflict, applyNotice, setApplyNotice, clearApplyError, prUrlInput, setPrUrlInput, fetchingPr, fetchingAttachment, ticketPatches, ticketPatchesLoading, tracAttachments, tracAttachmentsLoading, patchAttachments, loadTicketPatches, loadTracAttachments, choosePatchFile, previewPr, previewAttachment, previewPrFromInput, runPrSwitch, runApply } = useApplyPatch({ sitePath, project, workItem, showTracCards, isActive, tracTicket, appliedPatch, pullRequest, ticketBranches, setTicketError, setBlockedByTrunkWork, retryPrSwitchRef, ticketSwitchLifecycleRef, autoReadTicketRef, confirm, loadStatus, refreshDirty, runInstall, runScript, killCurrent, terminalStateRef, terminalKillRef, markTerminalRunning, writeToTerminal, refuseInTerminal, revealTerminal, watchStateRef, watchWaitersRef, applyHandOffRef, handOffToWatch, pauseWatcher, resumeWatcher, watchRebuildsOnStart });
1381+
// `refreshDirty` is `forgetChangedFiles` here: an apply, a revert and a pull
1382+
// request switch all change the tree, and the file list from before one is
1383+
// not an old version of the list after it (#669).
1384+
const { applyState, isApplying, applyKind, applySteps, applyStepStates, applyPreview, setApplyPreview, applyError, setApplyError, applyConflict, setApplyConflict, applyNotice, setApplyNotice, clearApplyError, prUrlInput, setPrUrlInput, fetchingPr, fetchingAttachment, ticketPatches, ticketPatchesLoading, tracAttachments, tracAttachmentsLoading, patchAttachments, loadTicketPatches, loadTracAttachments, choosePatchFile, previewPr, previewAttachment, previewPrFromInput, runPrSwitch, runApply } = useApplyPatch({ sitePath, project, workItem, showTracCards, isActive, tracTicket, appliedPatch, pullRequest, ticketBranches, setTicketError, setBlockedByTrunkWork, retryPrSwitchRef, ticketSwitchLifecycleRef, autoReadTicketRef, confirm, loadStatus, refreshDirty: forgetChangedFiles, runInstall, runScript, killCurrent, terminalStateRef, terminalKillRef, markTerminalRunning, writeToTerminal, refuseInTerminal, revealTerminal, watchStateRef, watchWaitersRef, applyHandOffRef, handOffToWatch, pauseWatcher, resumeWatcher, watchRebuildsOnStart });
13701385

13711386
// The tickets with work on this site (#108), in a card of their own (#240)
13721387
// below the Trac ticket card and the patch one — which ticket am I on, what

‎tests/e2e/journeys/patch-apply.spec.js‎

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ const affectedFile = ( page, file ) => page
167167
.getByRole( 'complementary', { name: 'Details of e2e-site', exact: true } )
168168
.getByRole( 'button', { name: file, exact: true } );
169169

170+
// The open site's details, and a file in them by its path.
171+
const details = ( page ) => page.getByRole( 'complementary', { name: 'Details of e2e-site', exact: true } );
172+
const group = ( page, name ) => details( page ).getByRole( 'heading', { level: 3, name, exact: true } );
173+
// Asks for the answer the app asks for when its window gets focus back,
174+
// which is when a contributor returns from their editor.
175+
const refocus = ( page ) => page.evaluate( () => window.dispatchEvent( new Event( 'focus' ) ) );
176+
170177
test( 'each file the applied patch changed opens in the editor from the details (#669)', async ( { session } ) => {
171178
const site = await makeSite( session );
172179
const { app, page } = await session.start( site.settings );
@@ -199,9 +206,15 @@ test( 'each file the applied patch changed opens in the editor from the details
199206
{ sitePath: site.dir, editorPath: EDITOR.path, relPath: 'src/wp-login.php' },
200207
] );
201208

202-
// INVARIANT — the list goes with the patch.
209+
// INVARIANT — the list goes with the patch: not hidden behind a dialog,
210+
// gone from details that can be read.
203211
await ui.revertPatchButton( page ).click();
204-
await expect( affectedFile( page, 'src/wp-login.php' ) ).toHaveCount( 0, { timeout: 60_000 } );
212+
await expect( ui.revertPatchButton( page ) ).toHaveCount( 0, { timeout: 60_000 } );
213+
await expect( page.getByText( 'A patch is being applied or reverted.' ) ).toHaveCount( 0, { timeout: 60_000 } );
214+
await expect( page.getByRole( 'dialog' ) ).toHaveCount( 0 );
215+
await expect( ui.confirmDialog( page ) ).toHaveCount( 0 );
216+
await expect( details( page ).getByRole( 'heading', { level: 2, name: 'Details', exact: true } ) ).toBeVisible();
217+
await expect( affectedFile( page, 'src/wp-login.php' ) ).toHaveCount( 0 );
205218
} );
206219

207220
test( 'choosing another application after a file would not open opens that file in it (#669)', async ( { session } ) => {
@@ -248,13 +261,6 @@ test( 'with no editor on the machine, an applied file is shown in the file manag
248261
expect( ( await asked() ).opens ).toEqual( [] );
249262
} );
250263

251-
// The open site's details, and a file in them by its path.
252-
const details = ( page ) => page.getByRole( 'complementary', { name: 'Details of e2e-site', exact: true } );
253-
const group = ( page, name ) => details( page ).getByRole( 'heading', { level: 3, name, exact: true } );
254-
// Asks for the answer the app asks for when its window gets focus back,
255-
// which is when a contributor returns from their editor.
256-
const refocus = ( page ) => page.evaluate( () => window.dispatchEvent( new Event( 'focus' ) ) );
257-
258264
test( 'files edited or added after a patch are told apart from the patch\'s own (#669)', async ( { session } ) => {
259265
const site = await makeSite( session );
260266
const { page } = await session.start( site.settings );
@@ -308,6 +314,54 @@ test( 'a ticket with no patch lists only the contributor\'s own changes (#669)',
308314
await expect( affectedFile( page, 'src/wp-login.php' ).locator( '..' ).getByText( 'new', { exact: true } ) ).toHaveCount( 0 );
309315
} );
310316

317+
test( 'discarding the contributor\'s changes takes them out of the list, with no refocus (#669)', async ( { session } ) => {
318+
const site = await makeSite( session );
319+
const { app, page } = await session.start( site.settings );
320+
// Counts the walks the list is measured by, so the discard below happens
321+
// with none in flight. One still running when it lands would answer after
322+
// it and clear the list by luck, and this would pass without the fix.
323+
await app.evaluate( ( { ipcMain } ) => {
324+
global.__e2eProbes = 0;
325+
const handler = ipcMain._invokeHandlers.get( 'git:unsubmitted-work' );
326+
ipcMain.removeHandler( 'git:unsubmitted-work' );
327+
ipcMain.handle( 'git:unsubmitted-work', ( ...args ) => {
328+
global.__e2eProbes += 1;
329+
return handler( ...args );
330+
} );
331+
} );
332+
const probes = () => app.evaluate( () => global.__e2eProbes );
333+
await ui.linkTicket( page, '60001' );
334+
335+
write( site.dir, 'src/new-helper.php', '<?php // mine\n' );
336+
await refocus( page );
337+
await expect( affectedFile( page, 'src/new-helper.php' ) ).toBeVisible( { timeout: 30_000 } );
338+
// Settled: the count holds still across a second.
339+
await expect.poll( async () => {
340+
const before = await probes();
341+
await page.waitForTimeout( 1000 );
342+
return ( await probes() ) === before;
343+
}, { timeout: 30_000 } ).toBe( true );
344+
345+
await ui.reviewChangesButton( page ).click();
346+
const review = page.getByRole( 'dialog', { name: 'Review & submit changes' } );
347+
await review.getByRole( 'button', { name: 'Discard all changes', exact: true } ).click();
348+
await ui.confirmYesButton( page, 'Discard changes' ).click();
349+
await expect.poll( () => fs.existsSync( path.join( site.dir, 'src/new-helper.php' ) ), { timeout: 30_000 } ).toBe( false );
350+
await ui.closeDialogButton( review ).click();
351+
// With every dialog gone and the details readable: while a modal is up the
352+
// page behind it is hidden from the accessibility tree, and the counts
353+
// below would be 0 whatever the list said.
354+
await expect( page.getByRole( 'dialog' ) ).toHaveCount( 0 );
355+
await expect( ui.confirmDialog( page ) ).toHaveCount( 0 );
356+
await expect( details( page ).getByRole( 'heading', { level: 2, name: 'Details', exact: true } ) ).toBeVisible();
357+
358+
// INVARIANT — the list follows the discard itself. The window never lost
359+
// focus, so a list that waited for the next focus would go on naming a
360+
// file that is gone.
361+
await expect( group( page, 'Your changes' ) ).toHaveCount( 0, { timeout: 30_000 } );
362+
await expect( affectedFile( page, 'src/new-helper.php' ) ).toHaveCount( 0 );
363+
} );
364+
311365
test( 'a patch that does not fit is refused, and writes nothing', async ( { session } ) => {
312366
const site = await makeSite( session );
313367
const { page } = await session.start( site.settings );

‎tests/e2e/journeys/pr-checkout.spec.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,15 @@ test( 'each file a checked-out pull request changed opens in the editor from the
213213
mine,
214214
] ) ).toBe( true );
215215

216-
// INVARIANT — and the list goes when the contributor leaves the pull request.
216+
// INVARIANT — and the list goes when the contributor leaves the pull
217+
// request, from details that can be read rather than behind a dialog.
217218
await ui.revertPrButton( page ).click();
218219
await expect( ui.prField( page ) ).toBeVisible( { timeout: 60_000 } );
220+
await expect( page.getByRole( 'dialog' ) ).toHaveCount( 0 );
221+
await expect( ui.confirmDialog( page ) ).toHaveCount( 0 );
222+
await expect( details.getByRole( 'heading', { level: 2, name: 'Details', exact: true } ) ).toBeVisible();
219223
await expect( file ).toHaveCount( 0 );
224+
await expect( mine ).toHaveCount( 0 );
220225
} );
221226

222227
test( 'a failed finish remains visible and offers no new patch source', async ( { session } ) => {

‎tests/unit/ipc-wiring.test.cjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5501,6 +5501,29 @@ test('branches:rebase moves the active ticket onto trunk, records the new base a
55015501
assert.equal(status.ticketBehindTrunk, false);
55025502
});
55035503

5504+
// The fingerprints go with the text (#669): the merge brings trunk's changes
5505+
// into files the patch wrote, and a file trunk changed is not one the
5506+
// contributor edited. Without them the record is one from before they were
5507+
// kept, which marks nothing. A record whose text an earlier move already
5508+
// dropped still has its fingerprints dropped.
5509+
test('branches:rebase drops the applied patch\'s fingerprints with its text (#669)', async () => {
5510+
for (const text of ['x', null]) {
5511+
const settings = rebaseFixture();
5512+
settings.values.siteMeta['/sites/wp'].branches['ticket/61002'].appliedPatch = { label: 'A.diff', text, files: ['f'], fingerprints: { f: 'aaa' } };
5513+
const main = loadMain({
5514+
stubs: {
5515+
...silentLogging(),
5516+
...settings.stubs,
5517+
'./trunk-update': { readTrunkInfo: async () => ({ trunkOid: 'new', trunkDate: 'd' }) },
5518+
'./ticket-branches': { rebaseOntoTrunk: async () => ({ rebased: true, from: 'old', to: 'new', parked: true, oid: 'wip2' }), currentBranchName: async () => 'ticket/61002' }
5519+
}
5520+
});
5521+
5522+
assert.equal((await main.invokeWith('branches:rebase', createIpcEvent(), '/sites/wp')).ok, true);
5523+
assert.deepEqual(settings.values.siteMeta['/sites/wp'].branches['ticket/61002'].appliedPatch, { label: 'A.diff', text: null, files: ['f'] }, `text ${text}`);
5524+
}
5525+
});
5526+
55045527
// The ref moves before the checkout. When only the checkout fails, the base
55055528
// has to follow the ref at once: every patch reads `baseOid`, and none of
55065529
// those readers is behind the marker.

0 commit comments

Comments
 (0)