mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user