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
6 changes: 5 additions & 1 deletion src/hub/links/hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
/** Installs `body` as the SSH program; `$D` is the test directory, `$@` the SSH arguments. */
const fakeSsh = (body: string): void => {
const program = join(dir, 'ssh');
writeFileSync(program, `#!/bin/sh\nD='${dir}'\nfor a in "$@"; do printf '%s\\0' "$a"; done > "$D/args"\necho $$ >> "$D/pids"\n${body}\n`);
// `args` appears whole, through a rename, so a reader never sees part of it.
writeFileSync(
program,
`#!/bin/sh\nD='${dir}'\nfor a in "$@"; do printf '%s\\0' "$a"; done > "$D/args.tmp"\nmv "$D/args.tmp" "$D/args"\necho $$ >> "$D/pids"\n${body}\n`,
);
chmodSync(program, 0o755);
vi.stubEnv('WTD_SSH', program);
};
Expand Down
13 changes: 12 additions & 1 deletion src/web/ui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ const Hubs = ({ client }: { client: HubClient }) => {
const [open, setOpen] = useState(false);
const stateRef = useRef(state);
const menuButton = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return undefined;
const outside = (event: PointerEvent): void => {
if (event.target instanceof Node && menu.current?.contains(event.target) !== true) setOpen(false);
};
document.addEventListener('pointerdown', outside, true);
return () => {
document.removeEventListener('pointerdown', outside, true);
};
}, [open]);
const hosts = client.store.hosts.value;
const dispatch = (action: HubAction): void => {
const { state: next, effect } = hubStep(stateRef.current, action);
Expand Down Expand Up @@ -174,7 +185,7 @@ const Hubs = ({ client }: { client: HubClient }) => {
/>
)}
{entries !== null && (
<span class="hubs">
<span class="hubs" ref={menu}>
<button
type="button"
ref={menuButton}
Expand Down
4 changes: 3 additions & 1 deletion src/web/ui/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,10 @@ export class View {
this.confirmation.value = null;
}

/** Opens the worktree menu at `x`, `y`; the main worktree has none, its only item being deletion. */
openWorktreeMenu(worktree: string, x: number, y: number): void {
this.worktreeMenu.value = { worktree, x, y };
const main = this.repo.value?.worktrees.find((w) => w.path === worktree)?.main === true;
this.worktreeMenu.value = main ? null : { worktree, x, y };
}

closeWorktreeMenu(): void {
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const TID = {
addRepoPath: 'add-repo-path',
/** In the add-repo dialog after a failed `addRepo`; text holds the error code. */
addRepoError: 'add-repo-error',
/** Sidebar entry; `data-path` = path, `title` = path then `<preset> <id>: <status>` per terminal (or "no terminals"), `aria-selected`, `data-prunable="true"`, `data-gone="true"`; holds a checkbox input unless gone; a primary click anywhere in it but on the checkbox selects it; right-click opens the worktree menu without selecting. */
/** Sidebar entry; `data-path` = path, `title` = path then `<preset> <id>: <status>` per terminal (or "no terminals"), `aria-selected`, `data-prunable="true"`, `data-gone="true"`; holds a checkbox input unless gone; a primary click anywhere in it but on the checkbox selects it; right-click opens the worktree menu without selecting, except on the main worktree, which has none. */
worktree: 'worktree',
/** The label inside a sidebar entry; Enter or Space on it selects the worktree. */
worktreeLabel: 'worktree-label',
Expand Down Expand Up @@ -132,7 +132,7 @@ export const TID = {
terminal: 'terminal',
/** Header button "Hubs", present only while there is a remote host; opens the list of `hubsEntry`; coloured like the selected repo tab, hovered or not. */
hubsMenu: 'hubs-menu',
/** The open Hubs menu holding the `hubsEntry` items, with a thin border. */
/** The open Hubs menu holding the `hubsEntry` items, with a thin border; Escape or a pointer press outside it and the Hubs button closes it. */
hubsList: 'hubs-list',
/** One per hub in the open Hubs menu, in order: text = "This machine" or the host name; `aria-current="true"` on the page's own hub. */
hubsEntry: 'hubs-entry',
Expand Down
15 changes: 15 additions & 0 deletions test/e2e/hubs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ test.describe('Hubs menu', () => {
await expect(page.locator(byTestId(TID.hubsMenu))).toBeFocused();
});

test('a press outside the menu closes it; the Hubs button toggles it', async ({ hub, page, ssh }) => {
ssh.failHost('box', RESOLVE_ERROR);
const repo = await home(hub, page, [{ name: 'box', ssh: 'box' }]);
const button = page.locator(byTestId(TID.hubsMenu));
const list = page.locator(byTestId(TID.hubsList));
await button.click();
await expect(list).toBeVisible();
await page.locator(repoTab(repo)).click();
await expect(list).toHaveCount(0);
await button.click();
await expect(list).toBeVisible();
await button.click();
await expect(list).toHaveCount(0);
});

test('the open menu has a thin border', async ({ hub, page, ssh }) => {
ssh.failHost('box', RESOLVE_ERROR);
await home(hub, page, [{ name: 'box', ssh: 'box' }]);
Expand Down
20 changes: 15 additions & 5 deletions test/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,14 +510,24 @@ test.describe('worktree status and deletion', () => {
);
});

test('the main worktree cannot be deleted, and Escape closes the menu', async ({ hub, page }) => {
const { repo } = makeRepoWith(hub, 'app', []);
test('right-clicking the main worktree opens no menu and closes an open one', async ({ hub, page }) => {
const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']);
const feat = worktrees[0] ?? '';
hub.writeRepos([repo]);
await openUi(hub, page);
await page.locator(worktreeEntry(repo)).click({ button: 'right' });
const item = page.locator(byTestId(TID.deleteWorktree));
await expect(item).toBeDisabled();
await expect(item).toHaveAttribute('title', 'The main worktree cannot be deleted');
await expect(page.locator(byTestId(TID.worktreeMenu))).toHaveCount(0);
await page.locator(worktreeEntry(feat)).click({ button: 'right' });
await expect(page.locator(byTestId(TID.worktreeMenu))).toBeVisible();
await page.locator(worktreeEntry(repo)).click({ button: 'right' });
await expect(page.locator(byTestId(TID.worktreeMenu))).toHaveCount(0);
});

test('Escape closes the worktree menu', async ({ hub, page }) => {
const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']);
hub.writeRepos([repo]);
await openUi(hub, page);
await page.locator(worktreeEntry(worktrees[0] ?? '')).click({ button: 'right' });
await page.locator(byTestId(TID.worktreeMenu)).press('Escape');
await expect(page.locator(byTestId(TID.worktreeMenu))).toHaveCount(0);
});
Expand Down
Loading