From 506df6c18e86d93f9a80d4c3e40e7824c09856d9 Mon Sep 17 00:00:00 2001 From: ketan0 Date: Mon, 2 Nov 2026 10:53:13 -0800 Subject: [PATCH] =?UTF-8?q?Add=20a=20=E2=8C=98K=20bar=20to=20jump=20to=20a?= =?UTF-8?q?ny=20board,=20PR=20or=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K (Ctrl+K) opens a host quick pick over every board in every repository and checkout: fuzzy match on title, repository, branch, PR and parent board, unread and recent first. It also runs New Whiteboard, Go Home, Settings and Theme, and a pasted GitHub PR link opens the PR's board or creates it in a checkout of its repository. In a code editor ⌘K still starts its chords. A board's pane now focuses its host when the canvas surface cannot take focus, so opening a board lands keyboard focus in it. Co-Authored-By: Claude Opus 5.5 Agent-Session: 01a0d12e-51ca-7230-9991-0ce7a73018b2 --- .../browser/parts/canvas/reviewCanvasPart.ts | 3 + .../browser/parts/reviewLinuxTitlebar.ts | 3 +- .../browser/parts/reviewTitlebarPart.ts | 3 +- .../browser/reviewTheme.contribution.ts | 1 + .../vs/review/common/reviewCommandPalette.ts | 3 + .../common/reviewGoToWhiteboard.test.ts | 101 ++++++ .../vs/review/common/reviewGoToWhiteboard.ts | 138 ++++++++ .../reviewGoToWhiteboard.contribution.ts | 307 ++++++++++++++++++ .../settings/reviewSettings.contribution.ts | 3 +- .../contrib/tabs/reviewHome.contribution.ts | 3 +- .../src/vs/review/review.common.main.ts | 1 + .../services/reviewApiCatalogService.ts | 10 + 12 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/contrib/quickaccess/reviewGoToWhiteboard.contribution.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index a405e37225..ad31b73168 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -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 { diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewLinuxTitlebar.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewLinuxTitlebar.ts index 0bd68a4874..d91f9e1a6c 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewLinuxTitlebar.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewLinuxTitlebar.ts @@ -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 { @@ -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(), diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewTitlebarPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewTitlebarPart.ts index e0f0bc6774..64449ce3a1 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewTitlebarPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/reviewTitlebarPart.ts @@ -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 @@ -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); diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/reviewTheme.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/browser/reviewTheme.contribution.ts index 8a2d366bde..fc4afa4d51 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/reviewTheme.contribution.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/reviewTheme.contribution.ts @@ -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. */ diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewCommandPalette.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewCommandPalette.ts index a8993adfce..7b7f929fa6 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewCommandPalette.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewCommandPalette.ts @@ -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; const stockPaletteCommands = new Set(['workbench.action.reloadWindow']); diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.test.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.test.ts new file mode 100644 index 0000000000..8638088626 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.test.ts @@ -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 => ({ + 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" }); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.ts new file mode 100644 index 0000000000..435d0eec42 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewGoToWhiteboard.ts @@ -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; + 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>(); + 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" }; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/quickaccess/reviewGoToWhiteboard.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/quickaccess/reviewGoToWhiteboard.contribution.ts new file mode 100644 index 0000000000..663ef1257c --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/quickaccess/reviewGoToWhiteboard.contribution.ts @@ -0,0 +1,307 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from "../../../base/common/codicons.js"; +import { toErrorMessage } from "../../../base/common/errorMessage.js"; +import { compareItemsByFuzzyScore, type FuzzyScorerCache, prepareQuery, scoreItemFuzzy } from "../../../base/common/fuzzyScorer.js"; +import { KeyCode, KeyMod } from "../../../base/common/keyCodes.js"; +import { DisposableStore } from "../../../base/common/lifecycle.js"; +import { posix } from "../../../base/common/path.js"; +import { ThemeIcon } from "../../../base/common/themables.js"; +import { EditorContextKeys } from "../../../editor/common/editorContextKeys.js"; +import { localize, localize2 } from "../../../nls.js"; +import { Action2, registerAction2 } from "../../../platform/actions/common/actions.js"; +import { ICommandService } from "../../../platform/commands/common/commands.js"; +import { ContextKeyExpr } from "../../../platform/contextkey/common/contextkey.js"; +import type { ServicesAccessor } from "../../../platform/instantiation/common/instantiation.js"; +import { IKeybindingService } from "../../../platform/keybinding/common/keybinding.js"; +import { KeybindingWeight } from "../../../platform/keybinding/common/keybindingsRegistry.js"; +import { INotificationService } from "../../../platform/notification/common/notification.js"; +import { IQuickInputService, type IQuickPickItem, type IQuickPickSeparator, quickPickItemScorerAccessor } from "../../../platform/quickinput/common/quickInput.js"; +import { registerThemingParticipant } from "../../../platform/theme/common/themeService.js"; +import { inQuickPickContext } from "../../../workbench/browser/quickaccess.js"; +import { IEditorService } from "../../../workbench/services/editor/common/editorService.js"; +import { boardOf } from "../../browser/parts/canvas/reviewCanvasEditorInput.js"; +import { showReviewNotification } from "../../browser/reviewNotification.js"; +import { REVIEW_OPEN_HOME_COMMAND_ID, REVIEW_OPEN_SETTINGS_COMMAND_ID } from "../../common/reviewCommandPalette.js"; +import { + jumpRows, + parsePullRequestLink, + pullRequestDestination, + RECENT_BOARD_LIMIT, + REVIEW_GO_TO_WHITEBOARD_COMMAND_ID, + type JumpRow, + type PullRequestDestination, + type PullRequestLink, +} from "../../common/reviewGoToWhiteboard.js"; +import { REVIEW_NEW_WHITEBOARD_COMMAND_ID } from "../../common/reviewNewWhiteboard.js"; +import { BOARD_ICONS, boardIconMarkup, PULL_REQUEST_STATE_ICONS } from "../../common/reviewProtocol.js"; +import { IReviewApiCatalogService } from "../../services/reviewApiCatalogService.js"; +import { IReviewCanvasEditorTabsService } from "../../services/reviewCanvasEditorTabsService.js"; + +/** A destination the bar can act on; "none" is a message, not a row. */ +type ReachableDestination = Exclude; + +type JumpItem = IQuickPickItem & ( + | { readonly kind: "board"; readonly reviewId: string } + | { readonly kind: "command"; readonly commandId: string } + | { readonly kind: "pullRequest"; readonly destination: ReachableDestination; readonly link: PullRequestLink } +); + +/** Names a row across re-renders of the list. */ +function itemKey(item: JumpItem | undefined): string | undefined { + if (item?.kind === "board") return item.reviewId; + return item?.kind === "command" ? item.commandId : item?.link.url; +} + +const COMMANDS: readonly { id: string; label: string; icon: ThemeIcon }[] = [ + { id: REVIEW_NEW_WHITEBOARD_COMMAND_ID, label: localize("review.goToWhiteboard.new", "New Whiteboard"), icon: Codicon.add }, + { id: REVIEW_OPEN_HOME_COMMAND_ID, label: localize("review.goToWhiteboard.home", "Go Home"), icon: Codicon.home }, + { id: REVIEW_OPEN_SETTINGS_COMMAND_ID, label: localize("review.goToWhiteboard.settings", "Settings"), icon: Codicon.settingsGear }, + { id: "workbench.action.selectTheme", label: localize("review.goToWhiteboard.theme", "Theme..."), icon: Codicon.symbolColor }, +]; + +/** TerminalContextKeyStrings.Focus; importing it would pull the terminal's + * modules into board windows, which have no terminal. */ +const TERMINAL_FOCUS = "terminalFocus"; + +const ICON_CLASS = "review-go-icon"; +const UNREAD_CLASS = "review-go-unread"; + +/** + * Boards draw with the icons the canvas and the sidebar draw, as masks on the + * quick pick's icon slot: the board or code board in muted ink, a PR in its + * state's color. An unread board has the sidebar's dot on its corner, cut + * out of the icon by a ring so it reads on any row background. + */ +registerThemingParticipant((_theme, collector) => { + const muted = "var(--vscode-descriptionForeground)"; + const colors: Record = { + board: muted, + code: muted, + open: "var(--review-success)", + draft: "var(--vscode-disabledForeground)", + merged: "var(--review-merged)", + closed: "var(--review-danger)", + }; + const icons = { ...BOARD_ICONS, ...PULL_REQUEST_STATE_ICONS }; + const root = `.quick-input-widget .monaco-icon-label.${ICON_CLASS}`; + collector.addRule(`${root}::before { content: ""; background-size: auto; background-position: 0 0; background-repeat: no-repeat; mask-repeat: no-repeat; mask-size: 14px 14px; mask-position: 1px center; }`); + for (const [kind, color] of Object.entries(colors)) { + const mask = `url("data:image/svg+xml;utf8,${encodeURIComponent(boardIconMarkup(icons[kind as JumpRow["icon"]]))}")`; + collector.addRule(`${root}-${kind}::before { background-color: ${color}; mask-image: ${mask}; --review-go-mask: ${mask}; }`); + } + // The dot's center sits on the icon's top right corner, as StatusDot's + // does: the icon is centered in the 16px slot codicons fill. Layers, top + // first: the dot, added to the icon less the ring. + const disc = (radius: number) => `radial-gradient(circle at 15px 4px, black ${radius}px, transparent ${radius + 0.5}px)`; + collector.addRule(`${root}.${UNREAD_CLASS}::before { background-image: radial-gradient(circle at 15px 4px, var(--review-accent) 3px, transparent 3.5px); mask-image: ${disc(3)}, var(--review-go-mask), ${disc(4.5)}; mask-size: auto, 14px 14px, auto; mask-position: 0 0, 1px center, 0 0; mask-composite: add, subtract, add; }`); +}); + +function boardItem(row: JumpRow): JumpItem { + return { + kind: "board", + reviewId: row.reviewId, + label: row.label, + description: row.description, + ariaLabel: row.unread ? localize("review.goToWhiteboard.unread", "{0}, unread, {1}", row.label, row.description) : `${row.label}, ${row.description}`, + iconClasses: [ICON_CLASS, `${ICON_CLASS}-${row.icon}`, ...(row.unread ? [UNREAD_CLASS] : [])], + }; +} + +function pullRequestItem(link: PullRequestLink, destination: ReachableDestination): JumpItem { + const row = { kind: "pullRequest", link, destination, description: link.slug, iconClasses: [ICON_CLASS, `${ICON_CLASS}-open`] } as const; + switch (destination.kind) { + case "open": + return { ...row, label: localize("review.goToWhiteboard.openPr", "Open the board for PR #{0}", link.number) }; + case "restore": + return { ...row, label: localize("review.goToWhiteboard.restorePr", "In Trash: restore the board for PR #{0}", link.number) }; + case "create": + return { + ...row, + label: localize("review.goToWhiteboard.createPr", "Create a whiteboard for PR #{0}", link.number), + description: localize("review.goToWhiteboard.inCheckout", "{0} · in {1}", link.slug, posix.basename(destination.repositoryPath)), + }; + } +} + +/** The items the query matches, best first. */ +function filtered(items: readonly T[], value: string): T[] { + const query = prepareQuery(value); + const cache: FuzzyScorerCache = Object.create(null); + return items + .map((item) => ({ item, score: scoreItemFuzzy(item, query, true, quickPickItemScorerAccessor, cache) })) + .filter(({ score }) => score.score > 0) + .sort((a, b) => compareItemsByFuzzyScore(a.item, b.item, query, true, quickPickItemScorerAccessor, cache)) + .map(({ item, score }) => ({ ...item, highlights: { label: score.labelMatch, description: score.descriptionMatch } })); +} + +/** + * ⌘K: jump to any board in any repository or checkout by title, repository, + * branch, PR or parent board, or run one of a few commands. A pasted PR link + * opens the PR's board, or creates it in a checkout of the PR's repository. + */ +class ReviewGoToWhiteboardAction extends Action2 { + constructor() { + super({ + id: REVIEW_GO_TO_WHITEBOARD_COMMAND_ID, + title: localize2("review.goToWhiteboard", "Go to Whiteboard..."), + f1: true, + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.KeyK, + // In a code editor, ⌘K stays the first key of its chords. In a + // terminal, should a window ever have one, it stays Terminal: Clear: + // this clause decides that, not which of the two registers last. + when: ContextKeyExpr.and(EditorContextKeys.editorTextFocus.toNegated(), ContextKeyExpr.not(TERMINAL_FOCUS), inQuickPickContext.negate()), + // Above upstream's ⌘K chords, which it would otherwise wait on. + weight: KeybindingWeight.WorkbenchContrib + 1, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const catalog = accessor.get(IReviewApiCatalogService); + const tabs = accessor.get(IReviewCanvasEditorTabsService); + const commands = accessor.get(ICommandService); + const keybindings = accessor.get(IKeybindingService); + const notifications = accessor.get(INotificationService); + const current = boardOf(accessor.get(IEditorService).activeEditor); + + const disposables = new DisposableStore(); + const picker = disposables.add(accessor.get(IQuickInputService).createQuickPick({ useSeparators: true })); + picker.placeholder = localize("review.goToWhiteboard.placeholder", "Go to a whiteboard, PR or command; paste a PR link to open its board"); + picker.matchOnLabel = picker.matchOnDescription = picker.matchOnDetail = picker.sortByLabel = false; + + const shortcut = (id: string) => { + const keybinding = keybindings.lookupKeybinding(id); + return keybinding?.hasMultipleChords() ? undefined : keybinding; + }; + const commandItems: JumpItem[] = COMMANDS.map(({ id, label, icon }) => ({ + kind: "command", + commandId: id, + label, + iconClass: ThemeIcon.asClassName(icon), + // Outside code editors ⌘K opens this bar, so its chords are not shown. + keybinding: shortcut(id), + })); + let boards: JumpItem[] = []; + // A failed restore or create, until the query changes. + let failure: string | undefined; + const render = () => { + const value = picker.value.trim(); + const link = parsePullRequestLink(value); + const destination = link && pullRequestDestination(link, catalog.reviews); + let notice: string | undefined; + if (link && destination) { + if (destination.kind === "none") notice = localize("review.goToWhiteboard.noCheckout", "No checkout of {0} here. Make a whiteboard in a checkout of it first.", link.slug); + picker.items = destination.kind === "none" ? [] : [pullRequestItem(link, destination)]; + } else if (value) { + // Boards and commands in one list, best match first. + const matches = filtered([...boards, ...commandItems], value); + if (!matches.length) notice = localize("review.goToWhiteboard.noMatch", "No whiteboard or command matches."); + picker.items = matches; + } else { + const recent = boards.slice(0, RECENT_BOARD_LIMIT); + const heading = recent.length < boards.length + ? localize("review.goToWhiteboard.recentOf", "recent, {0} of {1}", recent.length, boards.length) + : localize("review.goToWhiteboard.recent", "recent"); + picker.items = [ + ...(recent.length ? [{ type: "separator", label: heading } satisfies IQuickPickSeparator, ...recent] : []), + { type: "separator", label: localize("review.goToWhiteboard.commands", "commands") }, + ...commandItems, + ]; + } + picker.validationMessage = failure ?? notice; + }; + /** Follows the live list: the query, the active row and the scroll stay. */ + const refresh = () => { + const active = itemKey(picker.activeItems[0]); + boards = jumpRows(catalog.reviews, current).map(boardItem); + picker.keepScrollPosition = true; + render(); + const same = picker.items.find((item): item is JumpItem => item.type !== "separator" && itemKey(item) === active); + if (same) picker.activeItems = [same]; + picker.keepScrollPosition = false; + }; + + const open = async (reviewId: string, title: string) => { + picker.hide(); + // Opening the board focuses it. + await tabs.openApiReview(reviewId, title); + }; + /** + * Restoring or creating waits on the server, the bar busy. The board + * exists whether or not the bar is still up: closed, it no longer takes + * the reader anywhere, so a notification offers the board instead. + */ + let working = false; + const settle = async (work: () => Promise<{ reviewId: string; title: string }>) => { + working = picker.busy = true; + try { + const { reviewId, title } = await work(); + if (!disposables.isDisposed) return await open(reviewId, title); + showReviewNotification(notifications, { + kind: "success", + text: localize("review.goToWhiteboard.ready", "\"{0}\" is ready.", title), + actions: [{ label: localize("review.goToWhiteboard.openReady", "Open"), run: () => void tabs.openApiReview(reviewId, title) }], + }); + } catch (error) { + if (disposables.isDisposed) { + showReviewNotification(notifications, { kind: "error", text: toErrorMessage(error) }); + return; + } + failure = toErrorMessage(error); + render(); + } finally { + working = false; + if (!disposables.isDisposed) picker.busy = false; + } + }; + + disposables.add(picker.onDidChangeValue(() => { + failure = undefined; + render(); + })); + disposables.add(picker.onDidAccept(() => { + const [item] = picker.selectedItems; + if (!item || working) return; + if (item.kind === "board") return void open(item.reviewId, item.label); + if (item.kind === "command") { + picker.hide(); + return void commands.executeCommand(item.commandId); + } + const { destination, link } = item; + if (destination.kind === "open") return void open(destination.reviewId, destination.title); + if (destination.kind === "restore") { + return void settle(async () => { + await catalog.lifecycle(destination.reviewId, "active"); + return destination; + }); + } + void settle(async () => { + const reviewId = await catalog.createPullRequestReview(destination.repositoryPath, link.url); + const title = catalog.reviews.find((review) => review.reviewId === reviewId)?.title; + return { reviewId, title: title || localize("review.goToWhiteboard.prTitle", "PR #{0}", link.number) }; + }); + })); + disposables.add(catalog.onDidChange(refresh)); + disposables.add(picker.onDidHide(() => disposables.dispose())); + + refresh(); + picker.show(); + if (catalog.loaded) return; + // The first list is still on its way: commands show, boards follow. + picker.busy = true; + try { + await catalog.initialize(); + } catch (error) { + failure = toErrorMessage(error); + if (!disposables.isDisposed) render(); + } + if (!disposables.isDisposed) picker.busy = false; + } +} + +registerAction2(ReviewGoToWhiteboardAction); diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/settings/reviewSettings.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/settings/reviewSettings.contribution.ts index 4694aff294..fb21dc5130 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/settings/reviewSettings.contribution.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/settings/reviewSettings.contribution.ts @@ -8,6 +8,7 @@ import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Action2, registerAction2 } from '../../../platform/actions/common/actions.js'; import type { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js'; +import { REVIEW_OPEN_SETTINGS_COMMAND_ID } from '../../common/reviewCommandPalette.js'; import { IReviewCanvasEditorTabsService } from '../../services/reviewCanvasEditorTabsService.js'; /** @@ -17,7 +18,7 @@ import { IReviewCanvasEditorTabsService } from '../../services/reviewCanvasEdito class OpenReviewSettingsAction extends Action2 { constructor() { super({ - id: 'review.openSettings', + id: REVIEW_OPEN_SETTINGS_COMMAND_ID, title: localize2('review.openSettings', "Whiteboard: Settings..."), f1: true, keybinding: { diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/tabs/reviewHome.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/tabs/reviewHome.contribution.ts index 423596be71..69411d6476 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/tabs/reviewHome.contribution.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/tabs/reviewHome.contribution.ts @@ -15,6 +15,7 @@ import type { EditorInput } from '../../../workbench/common/editor/editorInput.j import { IEditorService } from '../../../workbench/services/editor/common/editorService.js'; import { IWorkbenchLayoutService } from '../../../workbench/services/layout/browser/layoutService.js'; import { ReviewCanvasEditorInput } from '../../browser/parts/canvas/reviewCanvasEditorInput.js'; +import { REVIEW_OPEN_HOME_COMMAND_ID } from '../../common/reviewCommandPalette.js'; import { IReviewCanvasEditorTabsService } from '../../services/reviewCanvasEditorTabsService.js'; import { REVIEW_HOME_ACTIVE as REVIEW_HOME_ACTIVE_KEY } from './reviewWindowKeys.js'; @@ -57,7 +58,7 @@ registerWorkbenchContribution2(ReviewHomeContribution.ID, ReviewHomeContribution registerAction2(class extends Action2 { constructor() { super({ - id: 'review.openHome', + id: REVIEW_OPEN_HOME_COMMAND_ID, title: localize2('review.openHome', "Go Home"), f1: true, keybinding: { diff --git a/apps/review-desktop/code-oss/src/vs/review/review.common.main.ts b/apps/review-desktop/code-oss/src/vs/review/review.common.main.ts index b068b75985..306cab4f7a 100644 --- a/apps/review-desktop/code-oss/src/vs/review/review.common.main.ts +++ b/apps/review-desktop/code-oss/src/vs/review/review.common.main.ts @@ -25,6 +25,7 @@ import "./contrib/explorer/reviewFileTree.contribution.js"; import "./contrib/extensions/reviewCuratedExtensions.contribution.js"; import "./contrib/install/reviewCliInstall.contribution.js"; import "./contrib/quickaccess/reviewQuickAccess.contribution.js"; +import "./contrib/quickaccess/reviewGoToWhiteboard.contribution.js"; import "./contrib/settings/reviewSettings.contribution.js"; import "./contrib/telemetry/reviewLspTelemetry.contribution.js"; import "./contrib/telemetry/reviewTelemetry.contribution.js"; diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiCatalogService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiCatalogService.ts index c707bac3f8..4cfbb2d263 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiCatalogService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiCatalogService.ts @@ -29,6 +29,9 @@ export interface IReviewApiCatalogService { /** Creates an empty top-level board, a plan in the checkout's project; returns its id. */ /** A new board in the repository's project, unnamed until the person types a title. */ createReview(repositoryPath: string, parentId?: string): Promise; + /** A board of the PR at `url`, made in the checkout; the server answers + * with the PR's existing board when it has one. Returns its id. */ + createPullRequestReview(repositoryPath: string, url: string): Promise; /** Deletes the board if it is still untouched: no title, no blocks; the server decides. */ discardReview(reviewId: string): Promise; } @@ -121,6 +124,13 @@ export class ReviewApiCatalogService extends Disposable implements IReviewApiCat return result.reviewId; } + async createPullRequestReview(repositoryPath: string, url: string): Promise { + await this.initialize(); + // open:false: the caller opens the tab itself. No title: the board takes the PR's. + const result = await this.client!.personCommand<{ reviewId: string }>({ type: "create", title: "", target: { kind: "pullRequest", repositoryPath, pullRequest: url }, open: false }); + return result.reviewId; + } + async discardReview(reviewId: string): Promise { await this.initialize(); const result = await this.client!.post<{ deleted: boolean }>(`/${encodeURIComponent(reviewId)}/discard`, {});