Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,9 @@ export class ReviewCanvasEditorPane extends EditorPane {

override focus(): void {
this.canvas.value?.focus();
// The canvas focuses its surface, which takes focus only through an
// element inside it. Opening a board from elsewhere still lands in it.
if (this.container && !this.container.contains(this.container.ownerDocument.activeElement)) this.container.focus();
}

showFind(): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { IKeybindingService } from '../../../platform/keybinding/common/keybindi
import { INativeHostService } from '../../../platform/native/common/native.js';
import { INotificationService } from '../../../platform/notification/common/notification.js';
import { WindowControlsStyle, getWindowControlsStyle } from '../../../platform/window/common/window.js';
import { REVIEW_OPEN_SETTINGS_COMMAND_ID } from '../../common/reviewCommandPalette.js';

/** Linux has no macOS application menu. Keep its commands next to navigation. */
export class ReviewLinuxTitlebar extends Disposable {
Expand Down Expand Up @@ -52,7 +53,7 @@ export class ReviewLinuxTitlebar extends Disposable {
autoSelectFirstItem: true,
getActions: () => [
command('workbench.action.showCommands', localize('review.menu.commands', "Command Palette...")),
command('review.openSettings', localize('review.menu.settings', "Settings...")),
command(REVIEW_OPEN_SETTINGS_COMMAND_ID, localize('review.menu.settings', "Settings...")),
command('review.manageExtensions', localize('review.menu.extensions', "Manage Extensions...")),
command('review.openWelcome', localize('review.menu.welcome', "Getting Started...")),
new Separator(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { CommandCenterControl } from '../../../workbench/browser/parts/titlebar/
import { INativeHostService } from '../../../platform/native/common/native.js';
import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { ReviewLinuxTitlebar } from './reviewLinuxTitlebar.js';
import { REVIEW_OPEN_HOME_COMMAND_ID } from '../../common/reviewCommandPalette.js';

/**
* The deliberately small Review titlebar. It owns the drag region and the macOS
Expand Down Expand Up @@ -229,7 +230,7 @@ export class ReviewTitlebarPart extends Part implements ITitlebarPart {
icon.appendChild(house);
button.appendChild(icon);

const run = () => void this.commandService.executeCommand('review.openHome');
const run = () => void this.commandService.executeCommand(REVIEW_OPEN_HOME_COMMAND_ID);
this._register(addDisposableListener(button, 'click', run));
this._register(addDisposableListener(button, 'keydown', event => {
const keyboardEvent = new StandardKeyboardEvent(event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const STATUS_COLORS = [
{ name: 'warning', candidates: ['editorWarning.foreground', 'terminal.ansiYellow'], dark: '#F2C14E', light: '#C98A0B' },
{ name: 'danger', candidates: ['editorError.foreground', 'terminal.ansiRed'], dark: '#F26A55', light: '#D8402C' },
{ name: 'success', candidates: ['terminal.ansiGreen', 'testing.iconPassed'], dark: '#4ED990', light: '#1B9A57' },
{ name: 'merged', candidates: [], dark: '#A371F7', light: '#8250DF' },
];

/** The first readable candidate the theme defines (registry defaults are VS Code's), else Whiteboard's. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { stripIcons } from '../../base/common/iconLabels.js';
import { localize } from '../../nls.js';
import type { ICommandAction } from '../../platform/action/common/action.js';

export const REVIEW_OPEN_HOME_COMMAND_ID = 'review.openHome';
export const REVIEW_OPEN_SETTINGS_COMMAND_ID = 'review.openSettings';

type CommandLabel = Pick<ICommandAction, 'title' | 'category'>;

const stockPaletteCommands = new Set(['workbench.action.reloadWindow']);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import { type JumpBoard, jumpRows, parsePullRequestLink, pullRequestDestination } from "./reviewGoToWhiteboard.js";

const whiteboard = { id: "wb", name: "devdotfast/whiteboard" };
const cloud = { id: "cloud", name: "devdotfast/cloud" };

const board = (reviewId: string, fields: Partial<JumpBoard> = {}): JumpBoard => ({
reviewId,
title: reviewId,
createdAt: "2026-01-01T00:00:00Z",
viewedAt: null,
trashedAt: null,
project: whiteboard,
repositoryName: "review",
repositoryPath: "/src/review",
...fields,
});

test("before anything is typed, unread boards lead, then the ones viewed last, without the board on screen", () => {
const rows = jumpRows([
board("old", { viewedAt: "2026-02-01T00:00:00Z" }),
board("recent", { viewedAt: "2026-03-01T00:00:00Z" }),
board("unread", { viewedAt: "2026-01-15T00:00:00Z", changed: true }),
board("trashed", { viewedAt: "2026-04-01T00:00:00Z", trashedAt: "2026-04-02T00:00:00Z" }),
board("current", { viewedAt: "2026-05-01T00:00:00Z" }),
], "current");

assert.deepEqual(rows.map((row) => [row.reviewId, row.unread]), [["unread", true], ["recent", false], ["old", false]]);
});

test("a row is found by its repository, PR, branch and parent plan", () => {
const rows = jumpRows([
board("plan", { title: "Plan: draw queue" }),
board("pr", {
title: "Share boards",
parentId: "plan",
target: { kind: "commits", repositoryId: "r", head: "h" },
origin: { branch: "ketan/share", pullRequestNumber: 1480, pullRequestTitle: "Share boards that live on a remote host" },
pullRequestState: "merged",
}),
board("ingest", { title: "", project: cloud, repositoryName: "cloud", repositoryPath: "/src/cloud" }),
]);
const byId = new Map(rows.map((row) => [row.reviewId, row]));
const pr = byId.get("pr")!;

for (const term of ["whiteboard", "#1480", "remote host", "ketan/share", "Plan: draw queue"]) assert.ok(pr.description.includes(term), term);
assert.equal(pr.icon, "merged");
assert.equal(byId.get("plan")!.icon, "board");
assert.equal(byId.get("ingest")!.label, "Untitled");
assert.ok(byId.get("ingest")!.description.includes("cloud"));
});

test("the checkout is named only when the project has boards in more than one", () => {
const named = (boards: JumpBoard[]) => jumpRows(boards).some((row) => row.description.includes("review-cmdk"));

assert.equal(named([board("main"), board("workspace", { repositoryName: "review-cmdk", repositoryPath: "/src/review-cmdk" })]), true);
assert.equal(named([board("workspace", { repositoryName: "review-cmdk", repositoryPath: "/src/review-cmdk" })]), false);
});

test("a pasted PR link is read without its tab, query or trailing text", () => {
assert.deepEqual(parsePullRequestLink(" https://github.com/devdotfast/whiteboard/pull/1477/files?w=1 "), {
slug: "devdotfast/whiteboard",
number: 1477,
url: "https://github.com/devdotfast/whiteboard/pull/1477",
});
assert.equal(parsePullRequestLink("github.com/devdotfast/whiteboard/pull/12")?.number, 12);
assert.equal(parsePullRequestLink("https://github.com/devdotfast/whiteboard/issues/12"), undefined);
assert.equal(parsePullRequestLink("draw queue"), undefined);
assert.equal(parsePullRequestLink("https://GHE.acme.dev/team/tool/pull/7#discussion")?.url, "https://ghe.acme.dev/team/tool/pull/7");
});

test("a PR link opens the PR's board, else creates one in the checkout of its repository used last", () => {
const boards = [
board("pr", { origin: { pullRequestUrl: "https://github.com/DevDotFast/Whiteboard/pull/1480" } }),
board("older", { viewedAt: "2026-01-02T00:00:00Z" }),
board("newer", { viewedAt: "2026-03-01T00:00:00Z", repositoryName: "review-cmdk", repositoryPath: "/src/review-cmdk" }),
board("fork", { project: { id: "fork", name: "ketan0/scratch" }, repositoryPath: "/src/scratch", origin: { pullRequestUrl: "https://github.com/acme/tool/pull/3" } }),
];

assert.deepEqual(pullRequestDestination(parsePullRequestLink("https://github.com/devdotfast/whiteboard/pull/1480")!, boards), { kind: "open", reviewId: "pr", title: "pr" });
assert.deepEqual(pullRequestDestination(parsePullRequestLink("https://github.com/devdotfast/whiteboard/pull/1477")!, boards), { kind: "create", repositoryPath: "/src/review-cmdk" });
// A checkout whose project has another name still counts once it holds one of the repository's PRs.
assert.deepEqual(pullRequestDestination(parsePullRequestLink("https://github.com/acme/tool/pull/9")!, boards), { kind: "create", repositoryPath: "/src/scratch" });
assert.deepEqual(pullRequestDestination(parsePullRequestLink("https://github.com/other/repo/pull/1")!, boards), { kind: "none" });
});

test("an untitled PR board keeps the title every surface shows", () => {
const untitled = board("pr", { title: "", origin: { pullRequestUrl: "https://github.com/devdotfast/whiteboard/pull/5" } });

assert.deepEqual(pullRequestDestination(parsePullRequestLink(untitled.origin!.pullRequestUrl!)!, [untitled]), { kind: "open", reviewId: "pr", title: "Untitled" });
});

test("a PR whose board is in Trash restores it, unless a live board has the PR too", () => {
const url = "https://github.com/devdotfast/whiteboard/pull/5";
const link = parsePullRequestLink(url)!;
const trashed = board("trashed", { trashedAt: "2026-04-02T00:00:00Z", origin: { pullRequestUrl: url } });

assert.equal(pullRequestDestination(link, [trashed]).kind, "restore");
assert.deepEqual(pullRequestDestination(link, [trashed, board("live", { origin: { pullRequestUrl: url } })]), { kind: "open", reviewId: "live", title: "live" });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) dev.fast. All rights reserved.
* Licensed under the MIT License. See LICENSE in the repository root for license information.
*--------------------------------------------------------------------------------------------*/

import { boardIconKind, boardTitle, type ReviewApiSummary } from "./reviewProtocol.js";

export const REVIEW_GO_TO_WHITEBOARD_COMMAND_ID = "review.goToWhiteboard";

/** What the ⌘K bar reads of a board. */
export type JumpBoard = Pick<
ReviewApiSummary,
| "reviewId"
| "title"
| "target"
| "origin"
| "pullRequestState"
| "project"
| "repositoryName"
| "repositoryPath"
| "parentId"
| "changed"
| "viewedAt"
| "createdAt"
| "trashedAt"
>;

/** A board as one row of the bar: the title is matched with the description. */
export interface JumpRow {
readonly reviewId: string;
readonly label: string;
readonly description: string;
/** "board" or "code", or the board's PR state. */
readonly icon: "board" | "code" | NonNullable<ReviewApiSummary["pullRequestState"]>;
readonly unread: boolean;
}

/** How many boards the bar lists before anything is typed, so its commands
* stay in view; typing searches them all. */
export const RECENT_BOARD_LIMIT = 12;

const projectKey = (board: JumpBoard) => board.project?.id ?? board.repositoryName;

/** The boards not in Trash, the one seen last first. */
function liveBoards(boards: readonly JumpBoard[]): JumpBoard[] {
const lastSeen = (board: JumpBoard) => board.viewedAt ?? board.createdAt;
return boards.filter((board) => !board.trashedAt).sort((a, b) => lastSeen(b).localeCompare(lastSeen(a)));
}

/**
* Every board the bar can open, in the order it lists them before anything
* is typed: unread first, then the ones viewed last. The board on screen is
* left out, so Enter on the first row always goes somewhere.
*/
export function jumpRows(boards: readonly JumpBoard[], current?: string): JumpRow[] {
const live = liveBoards(boards);
const byId = new Map(live.map((board) => [board.reviewId, board]));
// A checkout is named only when its project has boards in another one too.
const checkouts = new Map<string, Set<string | undefined>>();
for (const board of live) checkouts.set(projectKey(board), (checkouts.get(projectKey(board)) ?? new Set()).add(board.repositoryPath));

return live
.filter((board) => board.reviewId !== current)
.sort((a, b) => Number(Boolean(b.changed)) - Number(Boolean(a.changed)))
.map((board) => {
const pullRequest = board.origin?.pullRequestNumber;
const parent = board.parentId ? byId.get(board.parentId) : undefined;
const repository = board.project?.name.split("/").pop() || board.repositoryName;
const label = boardTitle(board.title);
const pullRequestTitle = board.origin?.pullRequestTitle;
const description = [
(checkouts.get(projectKey(board))?.size ?? 0) > 1 && board.repositoryName !== repository
? `${repository} (${board.repositoryName})`
: repository,
pullRequest &&
(pullRequestTitle && pullRequestTitle.trim().toLowerCase() !== label.trim().toLowerCase()
? `#${pullRequest} ${pullRequestTitle}`
: `#${pullRequest}`),
board.origin?.branch,
parent && `in ${boardTitle(parent.title)}`,
].filter(Boolean).join(" · ");

return {
reviewId: board.reviewId,
label,
description,
icon: pullRequest ? (board.pullRequestState ?? "open") : boardIconKind(board.target),
unread: Boolean(board.changed),
};
});
}

/** A pasted GitHub or GitHub Enterprise pull request link. */
export interface PullRequestLink {
/** owner/repo, as GitHub spells it. */
readonly slug: string;
readonly number: number;
/** The PR's canonical URL, without a tab or query. */
readonly url: string;
}

/** The PR a pasted link names; undefined for anything else. Any host, as the
* server takes: its gh sign-in decides whether the PR can be read. */
export function parsePullRequestLink(text: string): PullRequestLink | undefined {
const match = /^\s*(?:https?:\/\/)?(?:www\.)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)(?:[/?#]\S*)?\s*$/i.exec(text);
if (!match) return undefined;
const [, host, owner, repo, number] = match;
const slug = `${owner}/${repo}`;
return { slug, number: Number(number), url: `https://${host.toLowerCase()}/${slug}/pull/${number}` };
}

/** Where a pasted PR link leads. */
export type PullRequestDestination =
| { readonly kind: "open"; readonly reviewId: string; readonly title: string }
| { readonly kind: "restore"; readonly reviewId: string; readonly title: string }
| { readonly kind: "create"; readonly repositoryPath: string }
| { readonly kind: "none" };

/**
* The PR's own board when one is listed, restored first if it is in Trash:
* a create would only hand back that board. Else a checkout of the PR's
* repository to create one in, the one used last first. A checkout belongs
* to the repository when its project is named for it, as a GitHub remote
* names it, or when it already holds a board of one of the repository's PRs.
*/
export function pullRequestDestination(link: PullRequestLink, boards: readonly JumpBoard[]): PullRequestDestination {
const existing = boards
.filter((board) => board.origin?.pullRequestUrl !== undefined && parsePullRequestLink(board.origin.pullRequestUrl)?.url.toLowerCase() === link.url.toLowerCase())
.sort((a, b) => Number(Boolean(a.trashedAt)) - Number(Boolean(b.trashedAt)))[0];
if (existing) return { kind: existing.trashedAt ? "restore" : "open", reviewId: existing.reviewId, title: boardTitle(existing.title) };

const slug = link.slug.toLowerCase();
const ofRepository = (board: JumpBoard) =>
board.project?.name.toLowerCase() === slug ||
(board.origin?.pullRequestUrl !== undefined && parsePullRequestLink(board.origin.pullRequestUrl)?.slug.toLowerCase() === slug);
const checkout = liveBoards(boards).find((board) => board.repositoryPath && ofRepository(board))?.repositoryPath;
return checkout ? { kind: "create", repositoryPath: checkout } : { kind: "none" };
}
Loading
Loading