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
2 changes: 2 additions & 0 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The app's settings are in one dialog, opened from the cog at the bottom right of
## General

- **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release.
- **Start the server when I open a site** and **Start the build watch when I open a site** — off unless you turn them on. On, opening a site that is set up starts its [development server](./running-the-site), its build watch, or both, as it would if you pressed Start; on WordPress Core the server's start brings the watch with it. Nothing starts for a site whose setup, update or deletion is under way, and nothing already running is started again.
- **When I quit, running servers and build watches** — quitting always stops them, as it always has. **Stop them, and start them again next time** remembers which sites had a server or a watch running and starts them again when the app next opens, whichever site it opens on.
- **New sites go here** — the folder new sites are created in, each in a subfolder of its own. With one set, the [create-site dialog](./creating-a-site) starts on it; you can still pick another folder for one site without changing the setting. **Forget this folder** clears it, and the dialog goes back to asking each time. A folder that no longer exists is refused when you choose it.

## Sites
Expand Down
51 changes: 50 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ const {
const { fetchPullRequestHead, describePullRequestHead, pullRequestBranchState, checkoutPullRequest, leavePullRequest } = require('./pr-checkout');
const { prSubmissionRefusal, prCheckoutRefusal } = require('./renderer/pr-checkout.cjs');
const { createProgressThrottle, describeSwitchProgress } = require('./switch-progress.cjs');
const { getStore } = require('./settings-store');
const { getStore, peekStore } = require('./settings-store');
const { sitesToResume, readResume } = require('./resume-sites.cjs');

// 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
Expand Down Expand Up @@ -362,6 +363,9 @@ const runningScripts = {};
const cancelledChildren = new WeakSet();
/** @type {Record<string, string>} */
const runIdByDirectory = {};
// What each running script is, by run (#559): the quit reads which of them
// are a site's build watch, to start those again at the next launch.
const scriptByRunId = {};
// The same directory index for installs. The renderer knows a script's runId
// (`npm:run-script` returns it before the first log line) but never an
// installId — `runNpmInstall` keeps that correlation id to itself in the
Expand Down Expand Up @@ -2681,7 +2685,50 @@ app.on('window-all-closed', function () {
// Known residual gap on Windows: taskkill /T walks parent links at kill time,
// so a grandchild whose intermediate parent is already gone can survive
// (observed with grunt _watch) — tracked in #83.
// With the quit setting on 'restart' (#559), what is running is written down
// before it is swept, for the next launch to start again. Written through
// the store's synchronous accessor: this handler is not awaited, and the
// store has been made by now, at startup, for the locale. A store not yet
// made is a launch that read nothing, with nothing running to remember.
function rememberRunningSites() {
const s = peekStore();
if (!s) return;
const preferences = s.get('preferences') || {};
if (readSettings(preferences).quitBehavior !== 'restart') return;
const resume = sitesToResume({
servers: Object.keys(playgroundServers).filter((sitePath) => playgroundServers[sitePath]?.child),
scripts: Object.keys(runningScripts).map((runId) => scriptByRunId[runId]).filter(Boolean),
watchFor: (sitePath) => {
const meta = (s.get('siteMeta') || {})[sitePath];
return meta ? projectTypeForSite(meta).build.watch : null;
}
});
logEvent('quit', `remembering ${resume.servers.length} server(s) and ${resume.watches.length} watch(es) to start again`);
s.set('preferences', { ...preferences, resume });
}

// The list the quit left, read once by the window as it opens and then
// forgotten, so a launch that ends badly does not start it all again twice.
ipcMain.handle('sites:resume', async () => {
const s = await getStore();
const preferences = s.get('preferences') || {};
const resume = readResume(preferences);
if ('resume' in preferences) {
const { resume: _taken, ...rest } = preferences;
s.set('preferences', rest);
}
return { ok: true, ...resume };
});

app.on('before-quit', () => {
// Before the sweep, since it reads what is running; and unable to stop
// the sweep, since a store that cannot be written is no reason to leave
// every server and watch running.
try {
rememberRunningSites();
} catch (e) {
logError('quit', `could not remember what is running: ${String(e && e.message ? e.message : e)}`);
}
logEvent('quit', 'sweeping child processes');
const children = [
...Object.values(runningInstalls),
Expand Down Expand Up @@ -3758,6 +3805,7 @@ ipcMain.handle('npm:run-script', async (event, directoryPath, scriptName, script
relaxEnginesFromStart: true,
register: (child) => {
runningScripts[runId] = child;
scriptByRunId[runId] = { directoryPath, scriptName, scriptArgs };
runIdByDirectory[directoryPath] = runId;
trackDirectoryChild(directoryPath, child);
},
Expand All @@ -3768,6 +3816,7 @@ ipcMain.handle('npm:run-script', async (event, directoryPath, scriptName, script
event.sender.send('npm:run-script:done', { runId, code });
untrackDirectoryChild(directoryPath, runningScripts[runId]);
delete runningScripts[runId];
delete scriptByRunId[runId];
if (runIdByDirectory[directoryPath] === runId) {
delete runIdByDirectory[directoryPath];
}
Expand Down
4 changes: 4 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('api', {
,
// The PHP versions a site's server can run on, for the settings.
listPhpVersions: () => ipcRenderer.invoke('playground:php-versions')
,
// The sites whose server or watch the last quit stopped and is to start
// again (#559). Read once: the list is forgotten as it is read.
takeResumeList: () => ipcRenderer.invoke('sites:resume')
,
// The menu's "Settings…" asked for the dialog.
subscribeSettingsOpen: (handler) => {
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/auto-start.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

/**
* What opening a site starts (#559): the server, the watch, as the settings
* say, and what the last quit left for the site to start again.
*
* Each is an edge, consumed once: the row becoming the open one, and the
* list the quit left being read. The row keeps two flags for them and asks
* here, once the site is ready for a start, what to consume and what to
* start. Kept as a pure module so `node --test` covers the decision; the
* row keeps the gates (the site's status read, nothing under way) and the
* calls.
*
* @param {Object} root0
* @param {boolean} root0.open The row became the open one and has not asked since.
* @param {boolean} root0.isActive The row is the open one now.
* @param {boolean} root0.resumed The list the quit left has been acted on.
* @param {?Object} root0.resume What the list says for this site, `{ server, watch }`, or null for nothing.
* @param {Object} root0.settings The settings, with `autoStartServer` and `autoStartWatch`.
* @return {{consumeOpen: boolean, consumeResume: boolean, server: boolean, watch: boolean}} Which edges to consume, and what to start.
*/
function autoStartPlan({ open, isActive, resumed, resume, settings }) {
const plan = { consumeOpen: false, consumeResume: false, server: false, watch: false };
if (open && isActive) {
plan.consumeOpen = true;
plan.server = Boolean(settings.autoStartServer);
plan.watch = Boolean(settings.autoStartWatch);
}
if (!resumed && resume) {
plan.consumeResume = true;
if (resume.server) plan.server = true;
if (resume.watch) plan.watch = true;
}
return plan;
}

module.exports = { autoStartPlan };
53 changes: 52 additions & 1 deletion src/renderer/components/settings-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useEffect, useId, useMemo, useState } from 'react';
import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Button, Dialog, InputControl, Notice, SelectControl, Stack, SwitchControl, Tabs, Text } from '@wordpress/ui';
import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE } from '../settings-view.cjs';
import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, SYSTEM_LANGUAGE } from '../settings-view.cjs';
import { FolderField } from './folder-field.jsx';

// A notice here is read by its role, and is not also spoken: the dialog it
Expand Down Expand Up @@ -81,6 +81,56 @@ function LanguageControl({ settings, loaded, onChange }) {
);
}

// What a site opened starts, and what the quit does with what is running.
// The quit stops servers and watches either way: the one choice is whether
// the next launch starts them again.
function OpeningAndQuitting({ settings, onChange }) {
const [error, setError] = useState('');
const keep = async (key, value) => {
const result = await onChange(key, value);
setError(result?.ok ? '' : (result?.error || __('Could not keep that.')));
};
const items = quitItems();
return (
<Stack direction="column" gap="xl">
<Text variant="heading-lg" render={<h3 />}>{__('Opening and quitting')}</Text>
<SwitchControl
label={__('Start the server when I open a site')}
description={__('So the site and wp-admin are ready without a press. On WordPress Core the build watch starts with it.')}
checked={settings ? settings.autoStartServer : false}
disabled={!settings}
onCheckedChange={(checked) => keep('autoStartServer', checked)}
/>
<SwitchControl
label={__('Start the build watch when I open a site')}
description={__('So edits are compiled as they are saved.')}
checked={settings ? settings.autoStartWatch : false}
disabled={!settings}
onCheckedChange={(checked) => keep('autoStartWatch', checked)}
/>
<SelectControl
label={__('When I quit, running servers and build watches')}
description={__('Quitting always stops them; they can be started again when the app next opens.')}
items={items}
value={settings ? settings.quitBehavior : 'stop'}
disabled={!settings}
onValueChange={(value) => keep('quitBehavior', value)}
>
{items.map((item) => (
<SelectControl.Item key={item.value} value={item.value} label={item.label}>
<SelectControl.ItemLabel>{item.label}</SelectControl.ItemLabel>
</SelectControl.Item>
))}
</SelectControl>
{error ? (
<Notice.Root intent="error" role="alert" spokenMessage={SILENT}>
<Notice.Description>{error}</Notice.Description>
</Notice.Root>
) : null}
</Stack>
);
}

// The folder new sites go in. The system's dialog chooses it, main checks
// it, and what main then holds is what is shown: a folder it refused is
// said under the field and nothing changes.
Expand Down Expand Up @@ -108,6 +158,7 @@ function GeneralTab({ settings, loaded, onChange }) {
<Text variant="heading-lg" render={<h3 />}>{__('Appearance')}</Text>
<LanguageControl settings={settings} loaded={loaded} onChange={onChange} />
</Stack>
<OpeningAndQuitting settings={settings} onChange={onChange} />
<Stack direction="column" gap="xl">
<Text variant="heading-lg" render={<h3 />}>{__('New sites')}</Text>
<FolderField
Expand Down
49 changes: 47 additions & 2 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ import { TicketListCard } from './components/ticket-list.jsx';
import { useDetectedEditors } from './hooks/use-detected-editors.jsx';
import { useContributorProvenance } from './hooks/use-contributor-provenance.jsx';
import { useSettings } from './hooks/use-settings.jsx';
import { phpVersionChoice } from './settings-view.cjs';
import { phpVersionChoice, resumeFor } from './settings-view.cjs';
import { autoStartPlan } from './auto-start.cjs';
import { useNextActionCue } from './hooks/use-next-action-cue.jsx';
import { useSites } from './hooks/use-sites.jsx';
import { usePullRequest } from './hooks/use-pull-request.jsx';
Expand Down Expand Up @@ -137,6 +138,16 @@ function App() {
// The PHP a server starts on: the one set where the bundle has it, and
// the fallback where it does not, decided where the dialog decides it.
const startingPhp = settings ? phpVersionChoice({ versions: phpVersions?.versions, fallback: phpVersions?.fallback, stored: settings.phpVersion }).value : null;
// What the last quit stopped and is to start again (#559), read once as
// the window opens; main forgets it as it is read.
const [resume, setResume] = useState(null);
useEffect(() => {
let cancelled = false;
window.api.takeResumeList()
.then((res) => { if (!cancelled) setResume(res?.ok ? { servers: res.servers, watches: res.watches } : { servers: [], watches: [] }); })
.catch(() => { if (!cancelled) setResume({ servers: [], watches: [] }); });
return () => { cancelled = true; };
}, []);
const [settingsOpen, setSettingsOpen] = useState(false);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
Expand Down Expand Up @@ -690,6 +701,7 @@ function App() {
projectType={siteMeta?.[s]?.projectType}
settings={settings}
startingPhp={startingPhp}
resume={resumeFor(resume, s)}
onInitialized={onInitialized}
onSiteMetaPatch={onSiteMetaPatch}
onDelete={onDelete}
Expand Down Expand Up @@ -745,7 +757,7 @@ function App() {
);
}

function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, settings = null, startingPhp = null, onInitialized, onSiteMetaPatch, onDelete, onRename, onCreateSite, editor, wporg, isPending = false, isDeleting = false, setupLogs = '', isActive = false, switchProgress = null, carriedWork = null, onClearSwitchNotices = null, deepLink = null, onDeepLinkDone = null, detailsOpen = true, onToggleDetails = null, tray = null, onShowTray = null }) {
function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, settings = null, startingPhp = null, resume = null, onInitialized, onSiteMetaPatch, onDelete, onRename, onCreateSite, editor, wporg, isPending = false, isDeleting = false, setupLogs = '', isActive = false, switchProgress = null, carriedWork = null, onClearSwitchNotices = null, deepLink = null, onDeepLinkDone = null, detailsOpen = true, onToggleDetails = null, tray = null, onShowTray = null }) {
// The window's confirmation queue (#253): confirm(message) after an action
// completes, so the outcome is announced rather than left silent or buried in
// the terminal.
Expand Down Expand Up @@ -1248,6 +1260,39 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
// through everything above, and because what follows reads whether an
// update is under way.
const { updateState, isUpdating, updateHeld, updateWaitingOnWatch, updateSteps, updateStepStates, lastUpdateSummary, setLastUpdateSummary, dirtyModalOpen, setDirtyModalOpen, dirtySaving, dirtyFiles, dirtyError, startTrunkUpdate, dirtySaveAndUpdate, dirtyDiscardAndUpdate, retryInstallAndBuild } = useTrunkUpdate({ sitePath, confirm, confirmAnd, installing, building, runInstall, runScript, killCurrent, terminalStateRef, terminalKillRef, markTerminalRunning, writeToTerminal, refuseInTerminal, revealTerminal, watchStateRef, watchWaitersRef, pauseWatcher, resumeWatcher, watchRebuildsOnStart, loadStatus, refreshDirty, applyDiscardToNote });
// What opening the site starts (#559): the server, the watch, as the
// settings say, and what the last quit left for this site to start again.
// Which edge to consume and what to start is auto-start.cjs's; what is
// here is the gates and the calls. What an edge asks for waits until the
// site is ready for it, its status read and its setup done, and is
// dropped by a deactivation first. Nothing starts under an update, a
// setup or a deletion, and nothing that is already running or starting is
// started again: the watch is started after the server's start has
// answered, and only where that start did not bring it up.
const autoStart = useRef({ open: false, resumed: false });
useEffect(() => { autoStart.current.open = isActive; }, [isActive]);
const starters = useRef({ toggleDevServer, startBuildWatch });
useEffect(() => { starters.current = { toggleDevServer, startBuildWatch }; });
useEffect(() => {
if (statusLoading || !skipInit || !settings) return;
if (isPending || isDeleting || isUpdating || setupChainState !== 'idle') return;
const plan = autoStartPlan({ open: autoStart.current.open, isActive, resumed: autoStart.current.resumed, resume, settings });
if (plan.consumeOpen) autoStart.current.open = false;
if (plan.consumeResume) autoStart.current.resumed = true;
if (!plan.server && !plan.watch) return;
const watchUp = () => ['watching', 'building'].includes(watchStateRef.current);
const serverTried = plan.server && !isDevProcessActive;
(async () => {
if (serverTried) await starters.current.toggleDevServer();
// A server's start that found the terminal held has already been
// refused the watch, and said so; the watch is not asked for again.
if (plan.watch && !watchUp() && !(serverTried && terminalStateRef.current.running)) starters.current.startBuildWatch();
})().catch((err) => {
// eslint-disable-next-line no-console -- reaches the log file, see the note in useDetectedEditors.
console.error('Could not start what opening the site asks for:', err);
});
}, [isActive, resume, settings, statusLoading, skipInit, isPending, isDeleting, isUpdating, setupChainState, isDevProcessActive, watchStateRef, terminalStateRef]);

// What the page says about the site's two processes (#557), in the header
// and in the details alike. Decided in site-processes.cjs, and worked out
// here because an update of trunk holds both.
Expand Down
31 changes: 30 additions & 1 deletion src/renderer/settings-view.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,33 @@ function phpVersionChoice({ versions, fallback, stored }) {
};
}

module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE };
/**
* The entries of the control for what happens on quit (#559). Two and not
* the prototype's three: its "keep sites running" would leave children past
* the quit, which the quit sweep exists to end.
*
* @return {Array<{value: string, label: string}>}
*/
function quitItems() {
return [
{ value: 'stop', label: __('Stop them') },
{ value: 'restart', label: __('Stop them, and start them again next time') }
];
}

/**
* What the next launch starts for a site, from the list the last quit left:
* its server, its watch, both, or nothing.
*
* @param {?{servers: string[], watches: string[]}} resume The list, or null while it has not been read.
* @param {string} sitePath
* @return {?{server: boolean, watch: boolean}} What to start, or null for nothing.
*/
function resumeFor(resume, sitePath) {
if (!resume) return null;
const server = (resume.servers || []).includes(sitePath);
const watch = (resume.watches || []).includes(sitePath);
return server || watch ? { server, watch } : null;
}

module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, resumeFor, SYSTEM_LANGUAGE };
Loading
Loading