Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 101 additions & 10 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ const {
switchToBranch,
deleteTicketBranch
} = require('./ticket-branches');
const { createProgressThrottle, describeSwitchProgress } = require('./switch-progress.cjs');
const { getStore } = require('./settings-store');

// One name for the send-only progress channel (#173), shared with preload.js
// through the tests rather than by import — the renderer bundle and the main
// process do not share a module graph, and a rename that only lands on one side
// unsubscribes the panel silently.
const SWITCH_PROGRESS_CHANNEL = 'switch:progress';
const { parseTicketRef } = require('./renderer/trac-ticket.cjs');
const { parseHandle } = require('./wporg-handle.cjs');
const { parseEventName, buildProvenanceHeader, handoffFilename } = require('./patch-provenance.cjs');
Expand Down Expand Up @@ -926,6 +933,49 @@ async function withSwitchMarker(sitePath, run) {
}
}

/**
* Switch progress as terminal lines, for the trunk update (#173).
*
* `intervalMs: Infinity` suppresses everything except the first frame of a
* stage and the last one held when it turns over, which is a handful of lines per switch instead of
* thousands — the same module the panel drives live, in the mode an
* append-only log wants.
*
* @param {Function} sendLog Writes one chunk to the update's log stream.
* @return {{emit: Function, flush: Function}} Pass `emit` as `onProgress`.
*/
function updateSwitchLogger(sendLog) {
return createProgressThrottle({
intervalMs: Infinity,
onEmit: (payload) => sendLog(` ${describeSwitchProgress(payload)}\n`)
});
}

/**
* Streams a switch's progress to the window that asked for it (#173).
*
* Additive on purpose: the handler still awaits and returns its result exactly
* as before, and this only sends alongside. Restructuring into an
* invoke-then-done pair would have meant the renderer subscribing after the
* invoke answers — and the first checkout event lands about 4ms in, so the
* beginning of every switch would be lost.
*
* Sends are wrapped because the window can be gone by the time a multi-second
* checkout finishes, and a closed window must not turn a completed switch into
* a failure.
*
* @param {Object} event The IPC event, for its sender.
* @param {string} sitePath Which site the progress belongs to.
* @return {{emit: Function, flush: Function}} Pass `emit` as `onProgress`.
*/
function switchProgressReporter(event, sitePath) {
return createProgressThrottle({
onEmit: (payload) => {
try { event.sender.send(SWITCH_PROGRESS_CHANNEL, { sitePath, ...payload }); } catch {}
}
});
}

/**
* Where the state that describes the *work* lives: per branch once a ticket is
* being worked on, at site level on trunk and for sites that predate #108. One
Expand Down Expand Up @@ -1059,9 +1109,22 @@ ipcMain.handle('git:update-trunk', async (event, sitePath) => {
ticketBefore = branchBefore === TRUNK ? null : ticketIdFromRef(branchBefore);
if (branchBefore !== TRUNK) {
sendLog(`Parking your work on ${branchBefore} before updating…\n`);
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, {
baseOid: branchMetaBefore && branchMetaBefore.baseOid
}));
// The same progress the ticket panel shows (#173), but into the
// terminal this flow already streams to rather than onto the
// switch channel: one operation with two progress surfaces is
// how the two end up disagreeing. `Infinity` keeps it to one
// line per stage, since this log is append-only.
const parkLog = updateSwitchLogger(sendLog);
try {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, {
baseOid: branchMetaBefore && branchMetaBefore.baseOid,
onProgress: parkLog.emit
}));
} finally {
// The last line of the stage a park died in is the one line
// a contributor reading this log actually wants.
parkLog.flush();
}
await mergeSiteMeta(sitePath, { currentBranch: TRUNK });
}

Expand Down Expand Up @@ -1093,7 +1156,12 @@ ipcMain.handle('git:update-trunk', async (event, sitePath) => {
// the app never silently rebases anyone.
if (ticketBefore !== null) {
sendLog(`\nReturning to your work on ${branchBefore}…\n`);
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, branchBefore, {}));
const returnLog = updateSwitchLogger(sendLog);
try {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, branchBefore, { onProgress: returnLog.emit }));
} finally {
returnLog.flush();
}
await mergeSiteMeta(sitePath, { currentBranch: branchBefore, tracTicket: ticketBefore });
}
sendDone({ ok: true, ...result, branch: branchBefore });
Expand Down Expand Up @@ -1646,15 +1714,22 @@ async function withRegisteredSite(sitePath, run) {
// The site-level `tracTicket` is kept in step with the active branch so the
// handlers that read it (`git:list-ticket-patches`, `trac:list-attachments`,
// `site:status`) need no change.
ipcMain.handle('sites:set-ticket', async (_e, sitePath, ref) => withRegisteredSite(sitePath, async () => {
ipcMain.handle('sites:set-ticket', async (event, sitePath, ref) => withRegisteredSite(sitePath, async () => {
// Empty means unlink — the panel's Unlink button and a cleared field both
// land here, and neither is an error. The branch and its work stay; going
// back to trunk is not the same as throwing a ticket away.
const raw = typeof ref === 'string' ? ref.trim() : '';
if (!raw) {
const { ref: current, meta } = await activeBranch(sitePath, { migrate: true });
if (current !== TRUNK) {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, { baseOid: meta && meta.baseOid }));
const progress = switchProgressReporter(event, sitePath);
try {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, { baseOid: meta && meta.baseOid, onProgress: progress.emit }));
} finally {
// In a finally because a switch that dies mid-checkout is exactly
// when the last frame it reached is worth having.
progress.flush();
}
}
await mergeSiteMeta(sitePath, { tracTicket: null, currentBranch: TRUNK });
return { ok: true, ticket: null, branch: TRUNK };
Expand All @@ -1675,14 +1750,24 @@ ipcMain.handle('sites:set-ticket', async (_e, sitePath, ref) => withRegisteredSi
const known = await listTicketBranches(sitePath);
let baseOid;
if (known.includes(branchRef)) {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, branchRef, { baseOid: meta && meta.baseOid }));
const progress = switchProgressReporter(event, sitePath);
try {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, branchRef, { baseOid: meta && meta.baseOid, onProgress: progress.emit }));
} finally {
progress.flush();
}
baseOid = ((await readSiteMeta(sitePath)).branches || {})[branchRef]?.baseOid || null;
} else {
// Starting a ticket from another ticket parks that one first; from trunk
// the loose edits ride along into the new branch (that is deliberate —
// "I started editing, then realised which ticket this is").
if (current !== TRUNK) {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, { baseOid: meta && meta.baseOid }));
const progress = switchProgressReporter(event, sitePath);
try {
await withSwitchMarker(sitePath, () => switchToBranch(sitePath, TRUNK, { baseOid: meta && meta.baseOid, onProgress: progress.emit }));
} finally {
progress.flush();
}
}
({ baseOid } = await startTicketBranch(sitePath, parsed.id));
}
Expand Down Expand Up @@ -1712,11 +1797,17 @@ ipcMain.handle('branches:list', async (_e, sitePath) => withRegisteredSite(siteP
return { ok: true, current, branches };
}));

ipcMain.handle('branches:switch', async (_e, sitePath, targetRef) => withRegisteredSite(sitePath, async () => {
ipcMain.handle('branches:switch', async (event, sitePath, targetRef) => withRegisteredSite(sitePath, async () => {
const blocked = await midSwitchBlock(sitePath);
if (blocked) return blocked;
const { ref: current, meta } = await activeBranch(sitePath, { migrate: true });
const result = await withSwitchMarker(sitePath, () => switchToBranch(sitePath, targetRef, { baseOid: meta && meta.baseOid }));
const progress = switchProgressReporter(event, sitePath);
let result;
try {
result = await withSwitchMarker(sitePath, () => switchToBranch(sitePath, targetRef, { baseOid: meta && meta.baseOid, onProgress: progress.emit }));
} finally {
progress.flush();
}
const ticketId = ticketIdFromRef(targetRef);
if (targetRef !== TRUNK) {
await mergeBranchMeta(sitePath, targetRef, { lastUsedAt: new Date().toISOString() });
Expand Down
11 changes: 11 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ contextBridge.exposeInMainWorld('api', {
switchBranch: (sitePath, ref) => ipcRenderer.invoke('branches:switch', sitePath, ref)
,
deleteBranch: (sitePath, ref) => ipcRenderer.invoke('branches:delete', sitePath, ref)
,
// A long-lived subscription rather than the per-run pair the installs use
// (#173): a switch's first progress event lands a few milliseconds in, well
// before its `invoke` answers, and a listener attached afterwards would miss
// the start of every switch. Payloads carry `sitePath`, so one subscription
// serves every site.
subscribeSwitchProgress: (handler) => {
const h = (_e, payload) => handler && handler(payload);
ipcRenderer.on('switch:progress', h);
return () => ipcRenderer.removeListener('switch:progress', h);
}
,
subscribeSetupProgress: (handler) => {
const h = (_e, payload) => handler && handler(payload);
Expand Down
44 changes: 41 additions & 3 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { beginSetup, adoptSetupPath, discardSetup, rowPathAfterStatus } from './
import { parsePrRef } from '../patch-sources.cjs';
import { ticketUrl, attachUrl } from './trac-ticket.cjs';
import { ticketBranchRows } from './ticket-branch-list.cjs';
import { describeSwitchProgress } from '../switch-progress.cjs';
import { highlightDiff, hasDiffLines } from './diff-highlight.cjs';
import { carryTestMode } from './github-account.cjs';
import { changesNoteParts, discardOutcome, modalDiscardDisabled, discardBlocked, DISCARD_CONFIRM_MESSAGE } from './changes-note.cjs';
Expand Down Expand Up @@ -263,6 +264,10 @@ function App() {
const [createSubmitting, setCreateSubmitting] = useState(false);
const [setupLogsBySite, setSetupLogsBySite] = useState({});
const setupLogAliasRef = useRef({});
// Where a ticket switch has got to, per site (#173). Held here rather than in
// SiteRow because every row stays mounted — subscribing per row would open one
// listener per registered site and wake all of them for each other's events.
const [switchProgressBySite, setSwitchProgressBySite] = useState({});

const appendSetupLog = useCallback((siteTarget, message) => {
const key = siteTarget ? String(siteTarget) : '';
Expand Down Expand Up @@ -383,6 +388,21 @@ function App() {
return () => { if (unsubProg) unsubProg(); if (unsubStat) unsubStat(); };
}, [addPendingSite, appendSetupLog, applySetup, clearPendingSites, moveSetupLog]);

// Dropped when a switch begins, so a failed switch's last sentence is not the
// next one's first frame.
const clearSwitchProgress = useCallback((sitePath) => {
setSwitchProgressBySite((prev) => (prev[sitePath] ? { ...prev, [sitePath]: null } : prev));
}, []);

// One subscription for every site; the payload says which one (#173).
useEffect(() => {
const unsub = window.api.subscribeSwitchProgress((p) => {
if (!p || !p.sitePath) return;
setSwitchProgressBySite((prev) => ({ ...prev, [p.sitePath]: p.stage === 'done' ? null : p }));
});
return () => { if (unsub) unsub(); };
}, []);

// Refused while one is already running. Everything about this flow is
// single-file and always has been — one pending card, one terminal, one
// `clearPendingSites()` that clears them all — and `setupRowPathRef` is one
Expand Down Expand Up @@ -813,6 +833,8 @@ function App() {
wporg={wporg}
isPending={pendingSites.includes(s)}
setupLogs={setupLogsBySite[s] || ''}
switchProgress={switchProgressBySite[s] || null}
onClearSwitchProgress={clearSwitchProgress}
isActive={activeSite === s}
/>
</div>
Expand Down Expand Up @@ -996,7 +1018,7 @@ function TerminalCommandLink({ command, onPrefill, disabled }) {
);
}

function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSiteMetaPatch, onForget, onDelete, onRename, editor, wporg, isPending = false, setupLogs = '', isActive = false }) {
function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSiteMetaPatch, onForget, onDelete, onRename, editor, wporg, isPending = false, setupLogs = '', isActive = false, switchProgress = null, onClearSwitchProgress = null }) {
// Kept in a ref so loadStatus's dependency list stays [sitePath] — a
// recreated callback prop must not retrigger the status-loading effect.
const metaPatchRef = useRef(onSiteMetaPatch);
Expand Down Expand Up @@ -1448,6 +1470,8 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
setTicketSaving(true);
setTicketError('');
setBlockedByTrunkWork(null);
// The previous switch's last sentence must not be this one's first frame.
if (onClearSwitchProgress) onClearSwitchProgress(sitePath);
try {
const res = await window.api.setSiteTicket(sitePath, ref);
if (!res?.ok) {
Expand All @@ -1472,7 +1496,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
} finally {
setTicketSaving(false);
}
}, [sitePath, loadBranches, loadStatus]);
}, [sitePath, loadBranches, loadStatus, onClearSwitchProgress]);
const linkTicket = useCallback(() => saveTicket(ticketInput), [saveTicket, ticketInput]);
const unlinkTicket = useCallback(() => saveTicket(''), [saveTicket]);

Expand All @@ -1498,6 +1522,9 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
const discardTrunkWorkAndSwitch = useCallback(async (ref) => {
setTicketSaving(true);
setTicketError('');
// The refused attempt left its last frame behind — without this, the
// discard runs under a spinner describing a switch that never happened.
if (onClearSwitchProgress) onClearSwitchProgress(sitePath);
try {
const res = await window.api.discardChanges(sitePath);
if (!res?.ok) {
Expand All @@ -1515,7 +1542,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
// Outside the guard above: saveTicket owns the busy flag itself, and the
// discard has already succeeded — a failure here is about the switch.
await saveTicket(ref);
}, [sitePath, saveTicket]);
}, [sitePath, saveTicket, onClearSwitchProgress]);

// "Delete this ticket's work" (#108) — destroys the branch, which is why it
// sits behind a confirm while switching does not.
Expand Down Expand Up @@ -2114,6 +2141,15 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
// operations as well as on each other — the same trio every destructive
// control in this panel guards on.
const branchRows = ticketBranchRows({ branches: ticketBranches.branches, current: ticketBranches.current, tracTicket, now: Date.now() });
// What the switch is doing, while it does it (#173). Gated on the busy flag
// rather than merely cleared by it: the last sends can land after the invoke
// has already answered, which would flash a sentence under an idle panel.
const switchProgressLine = ticketSaving && switchProgress ? (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8, color: '#3c434a', fontSize: 12 }}>
<Spinner />
<span>{describeSwitchProgress(switchProgress)}</span>
</div>
) : null;
const ticketActionsBlocked = ticketSaving || deletingBranch !== null || updateState !== 'idle' || installing || building;
const renderBranchRows = (linked) => (
<div style={{ marginTop: 8, border: '1px solid #ddd', borderRadius: 6, overflow: 'hidden' }}>
Expand Down Expand Up @@ -3770,6 +3806,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
{ticketError ? (
<div role="alert" style={{ marginTop: 8, color: '#d63638', fontSize: 12 }}>{ticketError}</div>
) : null}
{switchProgressLine}
{blockedByTrunkWork ? (
<div style={{ marginTop: 8, padding: '10px 12px', background: '#fcf9e8', border: '1px solid #dba617', borderRadius: 6, color: '#6e5406', fontSize: 12 }}>
<div>
Expand Down Expand Up @@ -4328,6 +4365,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
style={{ justifyContent:'center' }}
>Link ticket</Button>
{ticketError ? <div role="alert" style={{ color:'#d63638', fontSize:12 }}>{ticketError}</div> : null}
{switchProgressLine}
</>
)}
</Destination>
Expand Down
Loading
Loading