docs: fix misplaced copy buttons by dropping terminal frames (#4090)

On every shell code block the copy button sat 40px below the top of the
code: on the second line of multi-line blocks, and hanging below
one-line blocks such as `wt merge --no-ff` on /merge/. Shell blocks were
Expressive Code terminal frames, and the terminal plugin deleted their
title bar, but Expressive Code still offsets the copy button by that
bar's height.

## Changes

- `docs/astro.config.mjs` sets `defaultProps: { frame: 'code' }`, so no
block is a terminal frame and there is no title bar to remove. The
plugin's header-removal hook and its `props.frame = 'terminal'` go, and
so does the `wt-commands-only` class: mobile wrapping in `custom.css`
now keys on whether a block contains captured output
(`:has(.wt-output)`).
- Blocks with several commands had a whole-block copy button and
per-line buttons in the same corner, handed over on hover by opacity. A
hidden button still takes clicks, so clicking a line's button could copy
the whole block, and on touch screens one button covered another. Most
of these blocks are lists of alternatives, so they now get only per-line
buttons, and the hover rule is deleted.
- Those blocks wrap (Expressive Code's `wrap` prop) unless they carry
captured output, since a per-line button sits at the end of its line and
scrolled out of view on the long `jq` examples on /list/. A per-line
button is no taller than its line, so buttons on adjacent lines no
longer overlap.
- The two multi-command blocks that only work run in order, the FAQ's
stash recipe and the `Equivalent to:` block under `wt step diff`'s **How
it works** (in `src/cli/step.rs`), are now `bash` fences, the form
`docs/CLAUDE.md` prescribes for a copyable recipe, so they keep one copy
button for the whole recipe. Terminal `--help` renders `console` and
`bash` fences alike (it strips `$ `), and the help snapshots are
unchanged.

## Side effect

Expressive Code strips comment lines only from what terminal frames
copy. The eight `bash` and `powershell` blocks with comments on
/shell-integration/ now copy them, as console blocks already did. Pasted
into zsh without `interactivecomments`, each comment line prints
`command not found: #`; the commands still run.

## Tests

A browser test checks every copy button on every page, at 393px with
touch and at 1376px, for three things: it sits on the line it copies, it
is inside the visible code, and it doesn't overlap another button. These
checks fail against worktrunk.dev and against a build without the wrap
and the height cap. The built-site and plugin tests now expect per-line
payloads and no block payload on multi-command blocks, and a plugin test
pins that blocks carrying output never wrap.

> _This was written by Claude Code on behalf of max-sixty_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013U96NY8qKtZhavBSwnfCYq

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximilian Roos
2026-09-13 10:00:25 -07:00
committed by GitHub
parent 28399c2ae5
commit cff085edf9
14 changed files with 184 additions and 162 deletions
+3 -2
View File
@@ -230,8 +230,9 @@ $ wt switch --create feature-auth
Starlight and Expressive Code create the frame and copy button. The Worktrunk
plugin highlights `console` commands as Bash, renders `$ ` as a prompt, and
makes mixed blocks copy only their commands. Comment lines and blank recipe
separators remain copyable; captured output does not. Snapshot-backed output
makes blocks copy their commands rather than their output. A block with one
command copies it with its comment lines and blank recipe separators; a block
with several gives each command its own copy button. Snapshot-backed output
gets its exact ANSI roles from the generated style manifest, while hand-written
output uses a conservative marker fallback. Generated Clap help fences carry
the `wt-command-reference` marker, which the plugin expands into semantic
+2
View File
@@ -65,6 +65,8 @@ export default defineConfig({
ThemeSelect: './src/components/ThemeSelect.astro',
},
expressiveCode: {
// Shell examples render as plain code, without terminal window chrome.
defaultProps: { frame: 'code' },
plugins: [pluginWorktrunkTerminal()],
themes: [worktrunkDarkCodeTheme, worktrunkLightCodeTheme],
useStarlightUiThemeColors: true,
+4 -4
View File
@@ -69,10 +69,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk
Stash the changes, create the worktree, then pop:
```console
$ git stash push -u # -u also stashes untracked files
$ wt switch --create feature # new branch off the default branch
$ git stash pop # changes reappear in the new worktree
```bash
git stash push -u # -u also stashes untracked files
wt switch --create feature # new branch off the default branch
git stash pop # changes reappear in the new worktree
```
The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean.
+4 -4
View File
@@ -425,10 +425,10 @@ $ wt step diff | delta
Equivalent to:
```console
$ cp "$(git rev-parse --git-dir)/index" /tmp/idx
$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```bash
cp "$(git rev-parse --git-dir)/index" /tmp/idx
GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```
`git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified.
+50 -62
View File
@@ -81,24 +81,6 @@ function copyControl(text, className) {
};
}
function removeTitlelessHeader(blockAst) {
blockAst.children = blockAst.children.filter((child) => {
if (
child?.type !== 'element'
|| child.tagName !== 'figcaption'
|| !child.properties?.className?.includes('header')
) return true;
const title = child.children?.find((element) => (
element?.type === 'element'
&& element.properties?.className?.includes('title')
));
return Boolean(
title?.children?.some((element) => element.type !== 'text' || element.value.trim()),
);
});
}
function markerTone(marker) {
if (/^[+↑⇡✓]/u.test(marker)) return 'positive';
if (/^[-↓⇣✗]/u.test(marker)) return 'negative';
@@ -353,90 +335,90 @@ function renderCommandReference(lineAst, text) {
}
/**
* Keeps terminal examples as ordinary Markdown while removing untitled frame
* chrome, adding command prompts, and copying commands rather than output.
* Keeps terminal examples as ordinary Markdown while adding command prompts
* and copying commands rather than output.
*/
export function pluginWorktrunkTerminal() {
return {
name: 'Worktrunk terminal prompts',
baseStyles: `
.expressive-code .frame.is-terminal .ec-line.wt-command .code::before {
.expressive-code .frame .ec-line.wt-command .code::before {
content: '$ ';
color: var(--wt-ink-muted);
user-select: none;
}
.expressive-code .frame.is-terminal .ec-line.wt-output .code {
.expressive-code .frame .ec-line.wt-output .code {
color: var(--wt-terminal-ink);
}
.expressive-code .frame.is-terminal .ec-line.wt-copyable .code {
.expressive-code .frame .ec-line.wt-copyable .code {
color: var(--wt-terminal-dim);
}
.expressive-code .frame.is-terminal .wt-positive {
.expressive-code .frame .wt-positive {
color: var(--sl-color-green-high);
font-weight: 650;
}
.expressive-code .frame.is-terminal .wt-negative {
.expressive-code .frame .wt-negative {
color: var(--sl-color-red-high);
font-weight: 650;
}
.expressive-code .frame.is-terminal .wt-warning {
.expressive-code .frame .wt-warning {
color: var(--sl-color-orange-high);
font-weight: 650;
}
.expressive-code .frame.is-terminal .wt-terminal-red {
.expressive-code .frame .wt-terminal-red {
color: var(--wt-terminal-red);
}
.expressive-code .frame.is-terminal .wt-terminal-green {
.expressive-code .frame .wt-terminal-green {
color: var(--wt-terminal-green);
}
.expressive-code .frame.is-terminal .wt-terminal-yellow {
.expressive-code .frame .wt-terminal-yellow {
color: var(--wt-terminal-yellow);
}
.expressive-code .frame.is-terminal .wt-terminal-blue {
.expressive-code .frame .wt-terminal-blue {
color: var(--wt-terminal-blue);
}
.expressive-code .frame.is-terminal .wt-terminal-magenta {
.expressive-code .frame .wt-terminal-magenta {
color: var(--wt-terminal-magenta);
}
.expressive-code .frame.is-terminal .wt-terminal-cyan {
.expressive-code .frame .wt-terminal-cyan {
color: var(--wt-terminal-cyan);
}
.expressive-code .frame.is-terminal .wt-terminal-gray {
.expressive-code .frame .wt-terminal-gray {
color: var(--wt-ink-muted);
}
.expressive-code .frame.is-terminal .wt-terminal-gutter {
.expressive-code .frame .wt-terminal-gutter {
display: inline-block;
background: var(--wt-terminal-gutter);
}
.expressive-code .frame.is-terminal .wt-terminal-bold {
.expressive-code .frame .wt-terminal-bold {
font-weight: 600;
}
.expressive-code .frame.is-terminal .wt-terminal-dim {
.expressive-code .frame .wt-terminal-dim {
color: var(--wt-terminal-dim);
opacity: 1;
}
.expressive-code .frame.is-terminal .wt-terminal-red.wt-terminal-dim {
.expressive-code .frame .wt-terminal-red.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-red) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-green.wt-terminal-dim {
.expressive-code .frame .wt-terminal-green.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-green) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-yellow.wt-terminal-dim {
.expressive-code .frame .wt-terminal-yellow.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-yellow) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-blue.wt-terminal-dim {
.expressive-code .frame .wt-terminal-blue.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-blue) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-magenta.wt-terminal-dim {
.expressive-code .frame .wt-terminal-magenta.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-magenta) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-cyan.wt-terminal-dim {
.expressive-code .frame .wt-terminal-cyan.wt-terminal-dim {
color: color-mix(in srgb, var(--wt-terminal-cyan) 62%, var(--wt-terminal-dim));
}
.expressive-code .frame.is-terminal .wt-terminal-italic {
.expressive-code .frame .wt-terminal-italic {
font-style: italic;
}
.expressive-code .frame.is-terminal .wt-terminal-underline {
.expressive-code .frame .wt-terminal-underline {
text-decoration: underline;
text-underline-offset: 0.14em;
}
@@ -446,8 +428,21 @@ export function pluginWorktrunkTerminal() {
.expressive-code .frame.wt-line-copies .ec-line.wt-command .code {
padding-inline-end: calc(2rem + var(--ec-codePadInl));
}
/* A line's control never outgrows its line, so adjacent controls can't overlap. */
.expressive-code .frame .wt-line-copy {
inset-block-start: 0;
inset-block: 0;
}
.expressive-code .frame .wt-line-copy button {
align-self: flex-start;
max-height: 100%;
}
/* Expressive Code sizes the icon from the button's height; keep its desktop size. */
.expressive-code .frame .wt-line-copy button::after {
margin: 0;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: 1.05rem;
mask-size: 1.05rem;
}
@media (hover: hover) {
.expressive-code .frame:hover .wt-line-copy button:not(:hover) {
@@ -456,11 +451,6 @@ export function pluginWorktrunkTerminal() {
.expressive-code .frame .ec-line:hover .wt-line-copy button:not(:hover) {
opacity: 0.75;
}
/* The block control and the first line's sit in the same corner, so
hand the corner to whichever line the pointer is on. */
.expressive-code .frame.wt-line-copies:has(.ec-line:hover) > .copy button:not(:hover) {
opacity: 0;
}
}
.expressive-code .frame.wt-command-reference .wt-help-heading {
color: var(--wt-copper);
@@ -506,7 +496,6 @@ export function pluginWorktrunkTerminal() {
if (codeBlock.language === 'console') {
consoleBlocks.add(codeBlock);
codeBlock.language = 'bash';
codeBlock.props.frame = 'terminal';
return;
}
if (codeBlock.metaOptions.value(commandReferenceMeta) === true) {
@@ -530,6 +519,10 @@ export function pluginWorktrunkTerminal() {
copyableLines.add(lineIndex);
}
}
// Per-line copy controls sit at the end of their line, so a block with
// several commands wraps rather than scrolling them out of view. Captured
// output keeps its columns, so a block carrying it never wraps.
if (commandLines.size > 1 && !hasOutput) codeBlock.props.wrap = true;
const outputLines = lines
.map((line, lineIndex) => ({ line, lineIndex }))
.filter(({ lineIndex }) => (
@@ -543,7 +536,6 @@ export function pluginWorktrunkTerminal() {
terminalBlocks.set(codeBlock, {
commandLines,
copyableLines,
hasOutput,
recordedByLine,
});
},
@@ -562,9 +554,8 @@ export function pluginWorktrunkTerminal() {
: 'wt-output';
addClass(renderData.lineAst, className);
const text = line?.text ?? codeBlock.getLines()[lineIndex].text;
// A block listing several commands is as often a menu of alternatives
// as a recipe, and nothing in the markup tells them apart — so each
// command line gets its own control alongside the block's.
// Most blocks listing several commands are menus of alternatives, so
// each command line gets its own control in place of the block's.
if (className === 'wt-command' && terminal.commandLines.size > 1) {
renderData.lineAst.children ??= [];
renderData.lineAst.children.push(copyControl(text, 'wt-line-copy'));
@@ -576,7 +567,6 @@ export function pluginWorktrunkTerminal() {
}
},
postprocessRenderedBlock({ codeBlock, renderData }) {
removeTitlelessHeader(renderData.blockAst);
if (commandReferenceBlocks.has(codeBlock)) {
addClass(renderData.blockAst, 'wt-command-reference');
// Generated `--help` output is reference material — its copy button
@@ -585,14 +575,12 @@ export function pluginWorktrunkTerminal() {
return;
}
const terminal = terminalBlocks.get(codeBlock);
if (!terminal) {
if (renderData.blockAst.properties?.className?.includes('is-terminal')) {
addClass(renderData.blockAst, 'wt-commands-only');
}
if (!terminal) return;
if (terminal.commandLines.size > 1) {
addClass(renderData.blockAst, 'wt-line-copies');
removeCopyControl(renderData.blockAst);
return;
}
if (!terminal.hasOutput) addClass(renderData.blockAst, 'wt-commands-only');
if (terminal.commandLines.size > 1) addClass(renderData.blockAst, 'wt-line-copies');
if (terminal.commandLines.size === 0 && terminal.copyableLines.size === 0) {
removeCopyControl(renderData.blockAst);
return;
+5 -8
View File
@@ -570,21 +570,18 @@ header.header {
-webkit-overflow-scrolling: touch;
}
/* Captured command output keeps its columns and scrolls; other code wraps. */
@media (max-width: 42rem) {
.expressive-code .frame:not(.is-terminal) pre,
.expressive-code .frame.is-terminal.wt-commands-only pre {
.expressive-code .frame:not(:has(.wt-output)) pre {
overflow-x: clip;
}
.expressive-code .frame:not(.is-terminal) .ec-line,
.expressive-code .frame:not(.is-terminal) .code,
.expressive-code .frame.is-terminal.wt-commands-only .ec-line,
.expressive-code .frame.is-terminal.wt-commands-only .code {
.expressive-code .frame:not(:has(.wt-output)) .ec-line,
.expressive-code .frame:not(:has(.wt-output)) .code {
min-width: 0;
}
.expressive-code .frame:not(.is-terminal) .code,
.expressive-code .frame.is-terminal.wt-commands-only .code {
.expressive-code .frame:not(:has(.wt-output)) .code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+60 -18
View File
@@ -120,7 +120,7 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout:
));
return contrast(effective, background);
});
const fixedTerminal = [...document.querySelectorAll('.frame.is-terminal')]
const fixedTerminal = [...document.querySelectorAll('.expressive-code .frame')]
.filter((frame) => frame.querySelector('.wt-output'))
.map((frame) => frame.querySelector('pre'))
.find((pre) => pre.scrollWidth > pre.clientWidth + 1);
@@ -133,10 +133,6 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout:
frames: [...document.querySelectorAll('.expressive-code .frame')].map((frame) => {
const pre = frame.querySelector('pre');
return {
className: frame.className,
managedTerminal: Boolean(
frame.querySelector('.wt-command, .wt-output, .wt-copyable'),
),
hasOutput: Boolean(frame.querySelector('.wt-output')),
frameLeft: frame.getBoundingClientRect().left,
frameRight: frame.getBoundingClientRect().right,
@@ -164,17 +160,7 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout:
frame.frameRight <= layout.viewportWidth + 1,
`${theme} ${width}px ${route} code ends offscreen`,
);
const terminal = frame.className.includes('is-terminal');
const commandsOnly = frame.className.includes('wt-commands-only');
if (terminal) {
assert.equal(
commandsOnly,
!frame.hasOutput,
`${theme} ${width}px ${route} misclassifies a terminal block`,
);
}
const wraps = !terminal || commandsOnly;
if (wraps) {
if (!frame.hasOutput) {
assert.ok(
frame.preScrollWidth <= frame.preClientWidth + 1,
`${theme} ${width}px ${route} wrappable code still scrolls horizontally`,
@@ -213,7 +199,7 @@ test('desktop code examples fit the content column', { timeout: 60_000 }, async
const rect = block.getBoundingClientRect();
return { left: rect.left, right: rect.right, text: block.textContent.trim().slice(0, 60) };
}),
terminals: [...document.querySelectorAll('.frame.is-terminal')]
terminals: [...document.querySelectorAll('.expressive-code .frame')]
.filter((frame) => frame.querySelector('.wt-output'))
.map((frame) => {
const pre = frame.querySelector('pre');
@@ -245,6 +231,62 @@ test('desktop code examples fit the content column', { timeout: 60_000 }, async
}
});
test('copy buttons sit in view on the line they copy without overlapping', { timeout: 60_000 }, async () => {
const browser = await webkit.launch();
try {
for (const { width, touch } of [{ width: 393, touch: true }, { width: 1376, touch: false }]) {
const page = await browser.newPage({
viewport: { width, height: 900 },
hasTouch: touch,
isMobile: touch,
});
for (const route of await sitemapRoutes()) {
await page.goto(`${baseUrl}${route}`, { waitUntil: 'domcontentloaded' });
const frames = await page.evaluate(() => {
for (const details of document.querySelectorAll('.sl-markdown-content details')) details.open = true;
return [...document.querySelectorAll('.expressive-code .frame')].map((frame) => {
const code = frame.querySelector('pre').getBoundingClientRect();
return {
text: frame.textContent.trim().slice(0, 60),
codeLeft: code.left,
codeRight: code.right,
buttons: [...frame.querySelectorAll('.copy button')].map((button) => {
// A block's control belongs on its first line.
const line = (button.closest('.ec-line') ?? frame.querySelector('.ec-line'))
.getBoundingClientRect();
const { top, bottom, left, right } = button.getBoundingClientRect();
return { top, bottom, left, right, lineTop: line.top, lineBottom: line.bottom };
}),
};
});
});
const label = (frame) => `${route} "${frame.text}" at ${width}px`;
for (const frame of frames) {
for (const [index, button] of frame.buttons.entries()) {
const center = (button.top + button.bottom) / 2;
assert.ok(
center >= button.lineTop && center <= button.lineBottom,
`${label(frame)}: copy button is off its line`,
);
assert.ok(
button.left >= frame.codeLeft - 1 && button.right <= frame.codeRight + 1,
`${label(frame)}: copy button is scrolled out of sight`,
);
assert.ok(
frame.buttons.slice(index + 1)
.every((other) => other.top >= button.bottom - 0.5 || other.bottom <= button.top + 0.5),
`${label(frame)}: copy buttons overlap`,
);
}
}
}
await page.close();
}
} finally {
await browser.close();
}
});
test('code artifacts keep their visual hierarchy in both themes', async () => {
const browser = await webkit.launch();
try {
@@ -380,7 +422,7 @@ test('code artifacts keep their visual hierarchy in both themes', async () => {
document.documentElement.dataset.theme = selectedTheme;
}, theme);
const listStyles = await page.evaluate(() => {
const frame = [...document.querySelectorAll('.frame.is-terminal')]
const frame = [...document.querySelectorAll('.expressive-code .frame')]
.find((candidate) => candidate.textContent.includes('feature-api'));
const normal = frame.querySelector('.wt-output .code');
const dim = frame.querySelector('.wt-terminal-dim:not(.wt-terminal-red)');
+8 -9
View File
@@ -365,7 +365,7 @@ test('output-only console blocks do not expose copy controls', async () => {
let outputOnlyBlocks = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
for (const match of html.matchAll(/<figure class="frame is-terminal[^"]*">([\s\S]*?)<\/figure>/g)) {
for (const match of html.matchAll(/<figure class="frame[^"]*">([\s\S]*?)<\/figure>/g)) {
const frame = match[1];
if (!/class="ec-line wt-output"/.test(frame)) continue;
if (/class="ec-line wt-(?:command|copyable)"/.test(frame)) continue;
@@ -381,7 +381,7 @@ test('command-bearing console blocks emit command-only copy payloads', async ()
let perLineBlocks = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
for (const match of html.matchAll(/<figure class="frame is-terminal[^"]*">([\s\S]*?)<\/figure>/g)) {
for (const match of html.matchAll(/<figure class="frame[^"]*">([\s\S]*?)<\/figure>/g)) {
const frame = match[1];
const lines = [...frame.matchAll(
/<div class="ec-line wt-(command|copyable|output)"><div class="code">([\s\S]*?)<\/div>/g,
@@ -393,26 +393,25 @@ test('command-bearing console blocks emit command-only copy payloads', async ()
commandBearingBlocks += 1;
// The block control carries the bare `copy` class; per-line controls add
// `wt-line-copy`, so this anchors on the block's own payload.
// `wt-line-copy`.
const encodedPayload = frame.match(
/<div class="copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/,
)?.[1];
assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`);
assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`);
// A block listing several commands is as often a menu of alternatives as
// a recipe, so each command line offers its own payload alongside the
// block's.
const commands = lines
.filter((line) => line[1] === 'command')
.map((line) => renderedText(line[2]).replace(/\n$/u, ''));
const perLine = [...frame.matchAll(
/<div class="copy wt-line-copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/g,
)].map((line) => renderedText(line[1]));
// Most blocks listing several commands are menus of alternatives, so
// each command line offers its own payload in place of the block's.
if (commands.length > 1) {
perLineBlocks += 1;
assert.equal(encodedPayload, undefined, `${page} offers a block copy beside per-line copies`);
assert.deepEqual(perLine, commands, `${page} per-line copy payloads do not match its commands`);
} else {
assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`);
assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`);
assert.deepEqual(perLine, [], `${page} adds per-line copy to a single-command block`);
}
}
+28 -35
View File
@@ -365,7 +365,7 @@ test('shell command roles preserve syntax outside the styled grammar', () => {
assert.equal(shellCommandSegments(source).map(({ text }) => text).join(''), source);
});
test('console blocks separate copyable recipes from output', () => {
test('console blocks give each of several commands its own copy control', () => {
const lines = ['# Recent', '$ wt list', '', '# Failed', '$ wt list --full'].map((text) => ({
text,
editText(start, end, replacement) {
@@ -377,41 +377,54 @@ test('console blocks separate copyable recipes from output', () => {
prepareCodeBlock(plugin, codeBlock);
assert.equal(codeBlock.language, 'bash');
assert.equal(codeBlock.props.frame, 'terminal');
const classes = lines.map((_, lineIndex) => {
const lineAsts = lines.map((_, lineIndex) => {
const renderData = { lineAst: { properties: {} } };
plugin.hooks.postprocessRenderedLine({ codeBlock, lineIndex, renderData });
return renderData.lineAst.properties.className;
return renderData.lineAst;
});
assert.deepEqual(
lines.map((line) => line.text),
['# Recent', 'wt list', '', '# Failed', 'wt list --full'],
);
assert.deepEqual(classes, [
assert.deepEqual(lineAsts.map((lineAst) => lineAst.properties.className), [
['wt-copyable'],
['wt-command'],
['wt-copyable'],
['wt-copyable'],
['wt-command'],
]);
assert.deepEqual(
lineAsts.map((lineAst) => lineAst.children?.[0].children[1].properties['data-code']),
[undefined, 'wt list', undefined, undefined, 'wt list --full'],
);
const copyButton = { type: 'element', tagName: 'button', properties: { 'data-code': 'stale' } };
const renderData = {
blockAst: {
children: [{
type: 'element',
properties: { className: ['copy'] },
children: [copyButton],
}],
properties: { className: ['frame'] },
children: [{ type: 'element', properties: { className: ['copy'] }, children: [] }],
},
};
plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData });
assert.equal(
copyButton.properties['data-code'],
'# Recent\u007fwt list\u007f\u007f# Failed\u007fwt list --full',
);
assert.deepEqual(renderData.blockAst.children, [], 'the block keeps its own copy control');
});
test('console blocks wrap several commands but never captured output', () => {
const wraps = (texts) => {
const lines = texts.map((text) => ({
text,
editText(start, end, replacement) {
this.text = this.text.slice(0, start) + replacement + this.text.slice(end);
},
}));
const codeBlock = { language: 'console', getLines: () => lines };
prepareCodeBlock(pluginWorktrunkTerminal(), codeBlock);
return codeBlock.props.wrap;
};
assert.equal(wraps(['$ wt list', '$ wt list --full']), true);
assert.equal(wraps(['$ wt list', '$ wt list --full', 'output']), undefined);
assert.equal(wraps(['$ wt list']), undefined);
});
test('console output and its blank lines stay out of copied commands', () => {
@@ -451,26 +464,6 @@ test('console output and its blank lines stay out of copied commands', () => {
assert.equal(copyButton.properties['data-code'], 'wt list\u007f# shell comment');
});
test('shell snippets use the command-only terminal classifier', () => {
const codeBlock = { language: 'bash', getLines: () => [] };
const blockAst = {
type: 'element',
tagName: 'figure',
properties: { className: ['frame', 'is-terminal'] },
children: [],
};
const plugin = pluginWorktrunkTerminal();
plugin.hooks.preprocessCode({ codeBlock });
plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData: { blockAst } });
assert.deepEqual(blockAst.properties.className, [
'frame',
'is-terminal',
'wt-commands-only',
]);
});
test('console output retains state-color semantics without ANSI in Markdown', () => {
assert.deepEqual(
semanticOutputSegments('@ feat +54 -5 ↑4 ↓1 ⇡3 ? ✓ done'),
+4 -4
View File
@@ -65,10 +65,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk
Stash the changes, create the worktree, then pop:
```console
$ git stash push -u # -u also stashes untracked files
$ wt switch --create feature # new branch off the default branch
$ git stash pop # changes reappear in the new worktree
```bash
git stash push -u # -u also stashes untracked files
wt switch --create feature # new branch off the default branch
git stash pop # changes reappear in the new worktree
```
The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean.
+4 -4
View File
@@ -413,10 +413,10 @@ $ wt step diff | delta
Equivalent to:
```console
$ cp "$(git rev-parse --git-dir)/index" /tmp/idx
$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```bash
cp "$(git rev-parse --git-dir)/index" /tmp/idx
GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```
`git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified.
+4 -4
View File
@@ -65,10 +65,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk
Stash the changes, create the worktree, then pop:
```console
$ git stash push -u # -u also stashes untracked files
$ wt switch --create feature # new branch off the default branch
$ git stash pop # changes reappear in the new worktree
```bash
git stash push -u # -u also stashes untracked files
wt switch --create feature # new branch off the default branch
git stash pop # changes reappear in the new worktree
```
The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean.
+4 -4
View File
@@ -413,10 +413,10 @@ $ wt step diff | delta
Equivalent to:
```console
$ cp "$(git rev-parse --git-dir)/index" /tmp/idx
$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```bash
cp "$(git rev-parse --git-dir)/index" /tmp/idx
GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```
`git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified.
+4 -4
View File
@@ -293,10 +293,10 @@ $ wt step diff | delta
Equivalent to:
```console
$ cp "$(git rev-parse --git-dir)/index" /tmp/idx
$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```bash
cp "$(git rev-parse --git-dir)/index" /tmp/idx
GIT_INDEX_FILE=/tmp/idx git add --intent-to-add .
GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch))
```
`git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified.