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
4 changes: 2 additions & 2 deletions .github/instructions/code-review.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Invariants. Breaking one is a `[fix here]` finding even when the code works on t

**Failure paths are part of the architecture.** The users cannot debug: a swallowed error is "the button did nothing" at a Contributor Day, with nobody able to diagnose it. Every spawn handles both `error` and `close` — Node documents that exit events "may or may not" follow a spawn failure, and `runNpmWithEngineRetry` in `src/main.js` shows the expected shape. No silent `catch`: an error that never reaches the renderer's log stream did not happen, from the user's chair. And a setup that dies halfway must leave the site registry consistent — no phantom site in `electron-store` for a directory that was never finished.

**Renderer decisions live in modules, not in `index.jsx`.** `src/renderer/index.jsx` mounts itself at module scope and cannot be loaded without a DOM, so nothing in the suite can reach it: a decision made there is untestable by construction. Anything with more than one branch — a string the user reads, a path joined, a status derived, a command parsed — belongs in a `src/renderer/*.cjs` module with its own test, leaving the component holding JSX, state assignments and the call. `site-folder.cjs` and `open-failure.cjs` are the shape.
**Renderer decisions live in modules, not in `index.jsx`.** `src/renderer/index.jsx` mounts itself at module scope and cannot be loaded without a DOM, so nothing in the suite can reach it: a decision made there is untestable by construction. The same holds for every other `.jsx` under `src/renderer/`, the components in `components/` and the hooks in `hooks/`: they are JSX and React state, which the suite has no way to run, so moving a decision into one of them moves it nowhere the suite can see. Anything with more than one branch — a string the user reads, a path joined, a status derived, a command parsed — belongs in a `src/renderer/*.cjs` module with its own test, leaving the component holding JSX, state assignments and the call. `site-folder.cjs` and `open-failure.cjs` are the shape.

State that has to survive an `await` inside the component goes in a ref, never in a variable scoped to an effect. Several of the component's effects have no dependency list, so they run on every render, and a value computed there before an IPC round trip is reset by the renders the round trip causes; the code after the `await` then reads a fresh default and takes the wrong branch, silently. ESLint does not flag it (`react-hooks/exhaustive-deps` has nothing to say about an effect with no list), and nothing in the suite can reach it, so it is caught here or not at all.

Expand Down Expand Up @@ -156,7 +156,7 @@ macOS and Windows are the primary targets; Linux artifacts are published too. CI

**Platform-conditional code needs both branches tested — from one machine.** The house pattern is dependency injection, not skipping: `tests/unit/win-spawn-patch.test.cjs` exercises the Windows paths from macOS by injecting `platform`, lookup and env rather than reading `process.platform`. A new platform split tested with `it.skip` on the other OS is a coverage hole CI will never close, since the suite runs on both platforms but each skips the other's branch.

**Renderer logic is tested through its module, so check it has one.** A PR that puts a branch inside `src/renderer/index.jsx` has written code the suite cannot reach — see the invariant in §1. The finding is the missing module, not the missing test.
**Renderer logic is tested through its module, so check it has one.** A PR that puts a branch inside `src/renderer/index.jsx`, or inside a component or a hook beside it, has written code the suite cannot reach — see the invariant in §1. The finding is the missing module, not the missing test.

**Scope stays proportional.** Missing tests on a touched line of legacy code is `[follow-up]`, not `[fix here]` — the strong rule applies to what the PR introduces, not to everything it brushes against.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ npm run dist:win # Windows (x64 by default)
npm run dist:win:arm64
```

The renderer is bundled by esbuild into `src/renderer/index.js` and `index.css`. Those are generated files, are not committed, and are rebuilt by `npm install`, `npm start` and every `npm run dist` — so there is no bundling step to remember after changing `src/renderer/index.jsx`. `npm run build:once` still exists if you want to rebuild on its own.
The renderer is bundled by esbuild into `src/renderer/index.js` and `index.css`. Those are generated files, are not committed, and are rebuilt by `npm install`, `npm start` and every `npm run dist` — so there is no bundling step to remember after changing `src/renderer/index.jsx` or the components and hooks it imports from `src/renderer/components/` and `src/renderer/hooks/`. `npm run build:once` still exists if you want to rebuild on its own.

Output goes to `dist/`. On Linux, `npm run dist` creates an AppImage. macOS packages use the build machine's architecture, and the published macOS builds currently target Apple Silicon (`arm64`) only.

Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Layers 1–3 are `npm test`. Layers 4 and 5 are the two `test:e2e` commands. Tha

## Where to put a new test

- **A pure function, a derived string, a decision with branches** → layer 1. If it lives inside `src/renderer/index.jsx` today, move it to a `src/renderer/*.cjs` module first: that component mounts at module scope and nothing in the suite can load it, so a decision made there is untestable by construction.
- **A pure function, a derived string, a decision with branches** → layer 1. If it lives inside `src/renderer/index.jsx` today, or in a `.jsx` under `src/renderer/components/` or `src/renderer/hooks/`, move it to a `src/renderer/*.cjs` module first: the entry point mounts at module scope, the rest is JSX and React state, and nothing in the suite can load either, so a decision made there is untestable by construction.
- **Anything that asks Git a question** → layer 2, against a real repository. A remote is a repository on disk reached over `file://`, as in `tests/unit/git-clone.integration.test.cjs` and `tests/unit/trunk-update-fetch.integration.test.cjs`; nothing starts a server.
- **A new IPC handler** → layer 3, plus the `contextBridge` entry in `src/preload.js`, which layer 5 checks is actually exposed.
- **A flow that spans the interface, the main process, Git and the store at once** → layer 4.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"files": [
"package.json",
"src/**/*",
"!src/renderer/index.jsx",
"!src/renderer/**/*.jsx",
"local-playground-web/**/*",
"!node_modules/dugite/git/libexec/git-core/*.dll",
"!node_modules/dugite/git/libexec/git-core/*.dylib",
Expand Down
53 changes: 53 additions & 0 deletions src/renderer/components/destination.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

// One destination for a finished patch (#166): what it is, what it costs to
// use, and what happens afterwards. The costs are the point — they are what the
// app used to leave the contributor to find out on their own — so every
// destination states one, in the same place and the same shape, rather than the
// cheap one being presented as the obvious choice.
export function Destination({ title, cost, after, children }) {
return (
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
<div style={{ fontWeight:600, fontSize:14, color:'#1d2327' }}>{title}</div>
<div style={{ fontSize:12, color:'#3c434a', lineHeight:1.5 }}>{cost}</div>
<div style={{ fontSize:12, color:'#6c6f72', lineHeight:1.5 }}>{after}</div>
{/*
The actions follow the prose rather than being pushed to the bottom of
the row: the destinations carry different numbers of controls, so
bottom-aligning them lines up nothing and leaves a hole above the
shorter one's button.
*/}
<div style={{ paddingTop:4, display:'flex', flexDirection:'column', gap:8 }}>{children}</div>
</div>
);
}

// The card around the destinations that ask the same thing of the contributor.
//
// Three equal cards said the three destinations were three variations on one
// choice. They are not: two of them save a file and stop, leaving the
// contributor to carry it somewhere, and the third signs them in and pushes on
// their behalf. That is the fork in the road, and a layout that hides it makes
// the reader rediscover it by reading all three in full.
//
// So the shared card is the grouping, and the hairline between destinations
// inside it says "another way to do the same kind of thing" — as against the
// gap between cards, which says "a different kind of thing". No group heading:
// the line above the grid names the split once, and a heading per card would
// say it twice while pushing the destinations themselves further down.
export function DestinationGroup({ children }) {
// Filtered because a conditional destination renders as false, and an empty
// section would draw a divider with nothing under it.
const destinations = React.Children.toArray(children).filter(Boolean);
return (
<div style={{ display:'flex', flexDirection:'column', border:'1px solid #dcdcde', borderRadius:10, background:'#fff' }}>
{destinations.map((destination, index) => (
// Position is the only identity a destination in a fixed list has, and
// the list is rebuilt whole when it changes.
<div key={index} style={{ padding:'14px 16px', borderTop: index === 0 ? 'none' : '1px solid #dcdcde' }}>
{destination}
</div>
))}
</div>
);
}
37 changes: 37 additions & 0 deletions src/renderer/components/diff-text.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { highlightDiff } from '../diff-highlight.cjs';

// What each kind of patch line looks like (#166). The classification is in
// diff-highlight.cjs; the colours are here because they are a property of this
// pane, not of a diff. Added and removed lines carry a wash as well as a
// foreground colour so the two are still distinguishable without colour vision
// — the sign in column 0 is the other half of that, and it is never hidden.
const DIFF_LINE_STYLES = {
add: { color: '#7ee787', background: 'rgba(46,160,67,0.18)' },
del: { color: '#ffa198', background: 'rgba(248,81,73,0.18)' },
hunk: { color: '#d2a8ff' },
meta: { color: '#79c0ff' },
header: { color: '#8b949e', fontStyle: 'italic' },
context: {}
};

// The patch, painted. An empty line still needs to occupy one: `\n` is appended
// per line rather than joining, so the last line of a patch that ends in a
// newline does not silently gain or lose one.
//
// Memoised on the text, because this renders inside SiteRow — which re-renders
// on every chunk a running dev server or watch task streams into its log. The
// patch has not changed; without this, each chunk re-splits it and hands React
// thousands of fresh spans to reconcile, on the same thread that has to paint
// the log.
export function DiffText({ text }) {
const lines = useMemo(() => highlightDiff(text), [text]);
if (!lines) return text;
return lines.map((line, index) => (
// A diff line has no identity beyond its position, and the whole pane is
// replaced when the patch changes.
<span key={index} style={{ display: 'block', ...DIFF_LINE_STYLES[line.kind] }}>
{line.text || ' '}
</span>
));
}
8 changes: 8 additions & 0 deletions src/renderer/components/discard-changes-link.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReasonedButton } from './reasoned-button.jsx';

// One discard action, wherever it is offered. Keeping the disabled rendering
// here means the ticket note cannot lose the explanation while the review
// modal keeps it (or vice versa).
export function DiscardChangesLink({ label, onClick, reason, style }) {
return <ReasonedButton variant="link" isDestructive onClick={onClick} reason={reason} style={style}>{label}</ReasonedButton>;
}
47 changes: 47 additions & 0 deletions src/renderer/components/log-text.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { highlightLog } from '../log-highlight.cjs';

// What each kind of log line looks like. Same split as the diff pane: the
// classification is in log-highlight.cjs, the colours are a property of this
// pane. Colour is never the only signal — the words `Fatal error`, `Warning`,
// `Deprecated` stay in the text, and the severity is a re-statement of them, so
// nothing is lost without colour vision.
const LOG_LINE_STYLES = {
fatal: { color: '#ffa198' },
warning: { color: '#ffb86c' },
deprecated: { color: '#e3d16a' },
notice: { color: '#e3d16a' },
// Stack frames and node's "(Use `…`)" follow-ups: they belong to the line
// above and are most of the volume in a full pane, so they recede.
trace: { color: '#8b949e' },
ready: { color: '#7ee787', fontWeight: 600 },
plain: {}
};
// The `[11-Aug-2026 …]` every debug.log line opens with. It is worth keeping —
// it is how two runs of the same request are told apart — but it is the same 26
// characters on every line, so it is the last thing that should catch the eye.
const LOG_STAMP_STYLE = { color: '#6e7681' };

// A log pane, painted. Memoised on the text because a running dev server streams
// chunks into it: without this, an unrelated SiteRow re-render re-splits the
// whole buffer. Only the tail is turned into per-line spans (see
// MAX_HIGHLIGHTED_LINES); everything older is one plain string, so the element
// count stays flat however long the server runs.
export function LogText({ text }) {
const painted = useMemo(() => highlightLog(text), [text]);
if (!painted) return null;
return (
<>
{painted.head}
{painted.lines.map((line, index) => (
// A log line has no identity beyond its position, and lines only ever
// arrive at the end.
<span key={index} style={{ display: 'block', ...LOG_LINE_STYLES[line.kind] }}>
{line.stamp ? <span style={LOG_STAMP_STYLE}>{line.stamp}</span> : null}
{/* A blank line still has to occupy one, same as in the diff pane. */}
{line.stamp === '' && line.text === '' ? ' ' : line.text}
</span>
))}
</>
);
}
28 changes: 28 additions & 0 deletions src/renderer/components/reasoned-button.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Button, Tooltip } from '@wordpress/components';

// A button that explains itself while disabled (#409). A reason disables it
// the accessible way: still in the tab order, `aria-disabled` rather than
// `disabled` so assistive technology reads it, the sentence as its
// description and as a tooltip. `title` would do neither, since Chromium
// shows no tooltip on a disabled control.
//
// The Tooltip is rendered whether or not there is a reason, and with no text
// it renders its anchor and no popover. The conditional version returned two
// different element types at the same position, so React remounted the
// button every time the gate flipped — which throws away exactly what
// `accessibleWhenDisabled` buys, since a keyboard user who just activated
// the control has the focused element destroyed under them and focus falls
// back to the document. `disabled` is passed through for gates that need no
// sentence (an empty input, not a blocked action).
export function ReasonedButton({ reason, disabled, children, ...props }) {
return (
<Tooltip text={reason || undefined} placement="bottom">
<Button
{...props}
disabled={reason ? true : disabled}
accessibleWhenDisabled={Boolean(reason)}
description={reason || undefined}
>{children}</Button>
</Tooltip>
);
}
17 changes: 17 additions & 0 deletions src/renderer/components/terminal-command-link.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Button } from '@wordpress/components';

// A command named in the hints under the Terminal (#182). Clicking it types the
// command at the prompt and stops there — running it is the contributor's
// keypress, so the hint teaches where these commands live instead of becoming a
// second, hidden set of build buttons. Rendered as plain text while something is
// running, since prefilling then would land in the middle of live output.
export function TerminalCommandLink({ command, onPrefill, disabled }) {
if (disabled) return <code>{command}</code>;
return (
<Button
variant="link"
onClick={() => onPrefill(command)}
style={{ fontSize: 12, fontFamily: 'monospace', height: 'auto' }}
>{command}</Button>
);
}
11 changes: 11 additions & 0 deletions src/renderer/hooks/use-confirmation.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext, useContext } from 'react';

// The one way any panel confirms a completed action (#253). `confirm(message)`
// queues a transient, screen-reader-announced notice; the provider in App renders
// the queue once for the window. The default is a no-op so a component rendered
// outside the provider (a test, say) does not throw on a stray confirm.
export const ConfirmationContext = createContext(() => {});

export function useConfirmation() {
return useContext(ConfirmationContext);
}
56 changes: 56 additions & 0 deletions src/renderer/hooks/use-contributor-provenance.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';

// Who this contributor is and where they are contributing from (#166), held
// once for the same reason the detected editors are: both are facts about the
// person or their machine, not about a checkout, so answering them in one site's
// patch modal must not leave every other site still asking.
export function useContributorProvenance() {
const [handle, setHandle] = useState(null);
const [event, setEvent] = useState(null);

useEffect(() => {
let cancelled = false;
window.api.getProvenance()
.then((res) => {
if (cancelled) return;
setHandle(res?.handle || null);
setEvent(res?.event || null);
})
// "nothing answered yet" is an ordinary state; "the store could not be
// read" is not, and the two must not look the same in the log. Same
// argument as in useDetectedEditors.
// eslint-disable-next-line no-console -- reaches the log file, see the note in useDetectedEditors.
.catch((err) => console.error('Could not read the remembered contributor details:', err));
return () => { cancelled = true; };
}, []);

// An empty ref forgets the field. A rejected invoke comes back as a refusal
// rather than being raised: the caller shows the message next to the input.
const rememberHandle = useCallback(async (ref) => {
let result;
try {
result = await window.api.setWporgHandle(ref);
} catch (err) {
// eslint-disable-next-line no-console -- see the note above.
console.error('Could not remember that WordPress.org handle:', err);
return { ok: false, error: String(err?.message ?? err) };
}
if (result?.ok) setHandle(result.handle || null);
return result;
}, []);

const rememberEvent = useCallback(async (ref) => {
let result;
try {
result = await window.api.setContributionEvent(ref);
} catch (err) {
// eslint-disable-next-line no-console -- see the note above.
console.error('Could not remember that event:', err);
return { ok: false, error: String(err?.message ?? err) };
}
if (result?.ok) setEvent(result.event || null);
return result;
}, []);

return { handle, event, rememberHandle, rememberEvent };
}
Loading
Loading