Compare commits

..

3 Commits

Author SHA1 Message Date
Pathik
0a4ffde319 Support moving multiple requests to another workspace (#396)
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-02-16 08:42:42 -08:00
Gregory Schier
cc4d598af3 Update skill 2026-02-16 06:02:03 -08:00
Davide Becker
f5d11cb6d3 Add support for client assertions in the OAuth 2 plugin (#395)
Co-authored-by: Davide Becker <github@reg.davide.me>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-02-14 07:38:54 -08:00
10 changed files with 336 additions and 66 deletions

View File

@@ -1,35 +1,46 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
allowed-tools: Bash(git worktree:*), Bash(gh pr:*), Bash(git branch:*)
---
Review a GitHub pull request in a new git worktree.
Check out a GitHub pull request for review.
## Usage
```
/review-pr <PR_NUMBER>
/check-out-pr <PR_NUMBER>
```
## What to do
1. List all open pull requests and ask the user to select one
1. If no PR number is provided, list all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. Extract the branch name from the PR
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
6. The post-checkout hook will automatically:
3. **Ask the user** whether they want to:
- **A) Check out in current directory** — simple `gh pr checkout <PR_NUMBER>`
- **B) Create a new worktree** — isolated copy at `../yaak-worktrees/pr-<PR_NUMBER>`
4. Follow the appropriate path below
## Option A: Check out in current directory
1. Run `gh pr checkout <PR_NUMBER>`
2. Inform the user which branch they're now on
## Option B: Create a new worktree
1. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
2. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
3. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
7. Inform the user:
4. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
## Example Output
### Example worktree output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123

View File

@@ -13,5 +13,11 @@
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
}
}

View File

@@ -4,26 +4,16 @@ import type { AccessTokenRawResponse } from './store';
export async function fetchAccessToken(
ctx: Context,
{
accessTokenUrl,
scope,
audience,
params,
grantType,
credentialsInBody,
clientId,
clientSecret,
}: {
args: {
clientId: string;
clientSecret: string;
grantType: string;
accessTokenUrl: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
params: HttpUrlParameter[];
},
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
): Promise<AccessTokenRawResponse> {
const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;
console.log('[oauth2] Getting access token', accessTokenUrl);
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
@@ -34,7 +24,10 @@ export async function fetchAccessToken(
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
],
};
@@ -42,11 +35,24 @@ export async function fetchAccessToken(
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
if (credentialsInBody) {
if ('clientAssertion' in args) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({ name: 'client_secret', value: clientSecret });
httpRequest.body?.form.push({
name: 'client_assertion_type',
value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
});
httpRequest.body?.form.push({
name: 'client_assertion',
value: args.clientAssertion,
});
} else if (args.credentialsInBody) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({
name: 'client_secret',
value: args.clientSecret,
});
} else {
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`;
httpRequest.headers?.push({ name: 'Authorization', value });
}

View File

@@ -1,9 +1,99 @@
import { createPrivateKey, randomUUID } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import jwt, { type Algorithm } from 'jsonwebtoken';
import { fetchAccessToken } from '../fetchAccessToken';
import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export const jwtAlgorithms = [
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'none',
] as const;
export const defaultJwtAlgorithm = jwtAlgorithms[0];
/**
* Build a signed JWT for the client_assertion parameter (RFC 7523).
*
* The `secret` value is auto-detected as one of:
* - **JWK** a JSON string containing a private-key object (has a `kty` field).
* - **PEM** a string whose trimmed form starts with `-----`.
* - **HMAC secret** anything else, used as-is for HS* algorithms.
*/
function buildClientAssertionJwt(params: {
clientId: string;
accessTokenUrl: string;
secret: string;
algorithm: Algorithm;
}): string {
const { clientId, accessTokenUrl, secret, algorithm } = params;
const isHmac = algorithm.startsWith('HS') || algorithm === 'none';
// Resolve the signing key depending on format
let signingKey: jwt.Secret;
let kid: string | undefined;
const trimmed = secret.trim();
if (isHmac) {
// HMAC algorithms use the raw secret (string or Buffer)
signingKey = secret;
} else if (trimmed.startsWith('{')) {
// Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.
let jwk: any;
try {
jwk = JSON.parse(trimmed);
} catch {
throw new Error('Client Assertion secret looks like JSON but is not valid');
}
kid = jwk?.kid;
signingKey = createPrivateKey({ key: jwk, format: 'jwk' });
} else if (trimmed.startsWith('-----')) {
// PEM-encoded key
signingKey = createPrivateKey({ key: trimmed, format: 'pem' });
} else {
throw new Error(
'Client Assertion secret must be a JWK JSON object, a PEM-encoded key ' +
'(starting with -----), or a raw secret for HMAC algorithms.',
);
}
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: clientId,
sub: clientId,
aud: accessTokenUrl,
iat: now,
exp: now + 300, // 5 minutes
jti: randomUUID(),
};
// Build the JWT header; include "kid" when available
const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' };
if (kid) {
header.kid = kid;
}
return jwt.sign(JSON.stringify(payload), signingKey, {
algorithm: algorithm as jwt.Algorithm,
header,
});
}
export async function getClientCredentials(
ctx: Context,
contextId: string,
@@ -14,6 +104,10 @@ export async function getClientCredentials(
scope,
audience,
credentialsInBody,
clientAssertionSecret,
clientAssertionSecretBase64,
clientCredentialsMethod,
clientAssertionAlgorithm,
}: {
accessTokenUrl: string;
clientId: string;
@@ -21,6 +115,10 @@ export async function getClientCredentials(
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
clientAssertionSecret: string;
clientAssertionSecretBase64: boolean;
clientCredentialsMethod: string;
clientAssertionAlgorithm: string;
},
) {
const tokenArgs: TokenStoreArgs = {
@@ -34,16 +132,38 @@ export async function getClientCredentials(
return token;
}
const response = await fetchAccessToken(ctx, {
const common: Omit<
Parameters<typeof fetchAccessToken>[1],
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
> = {
grantType: 'client_credentials',
accessTokenUrl,
audience,
clientId,
clientSecret,
scope,
credentialsInBody,
params: [],
});
};
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
clientCredentialsMethod === 'client_assertion'
? {
...common,
clientAssertion: buildClientAssertionJwt({
clientId,
algorithm: clientAssertionAlgorithm as Algorithm,
accessTokenUrl,
secret: clientAssertionSecretBase64
? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8')
: clientAssertionSecret,
}),
}
: {
...common,
clientSecret,
credentialsInBody,
};
const response = await fetchAccessToken(ctx, fetchParams);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -5,6 +5,7 @@ import type {
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
import {
type CallbackType,
@@ -14,7 +15,11 @@ import {
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
import { getClientCredentials } from './grants/clientCredentials';
import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store';
@@ -97,7 +102,10 @@ export const plugin: PluginDefinition = {
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({ message: 'No token to copy', color: 'warning' });
await ctx.toast.show({
message: 'No token to copy',
color: 'warning',
});
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
@@ -118,9 +126,15 @@ export const plugin: PluginDefinition = {
clientId: stringArg(values, 'clientId'),
};
if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({ message: 'Token deleted', color: 'success' });
await ctx.toast.show({
message: 'Token deleted',
color: 'success',
});
} else {
await ctx.toast.show({ message: 'No token to delete', color: 'warning' });
await ctx.toast.show({
message: 'No token to delete',
color: 'warning',
});
}
},
},
@@ -139,6 +153,19 @@ export const plugin: PluginDefinition = {
defaultValue: defaultGrantType,
options: grantTypes,
},
{
type: 'select',
name: 'clientCredentialsMethod',
label: 'Authentication Method',
description:
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
defaultValue: 'client_secret',
options: [
{ label: 'Client Secret', value: 'client_secret' },
{ label: 'Client Assertion', value: 'client_assertion' },
],
dynamic: hiddenIfNot(['client_credentials']),
},
{
type: 'text',
name: 'clientId',
@@ -151,7 +178,47 @@ export const plugin: PluginDefinition = {
label: 'Client Secret',
optional: true,
password: true,
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
dynamic: hiddenIfNot(
['authorization_code', 'password', 'client_credentials'],
(values) => values.clientCredentialsMethod === 'client_secret',
),
},
{
type: 'select',
name: 'clientAssertionAlgorithm',
label: 'JWT Algorithm',
defaultValue: defaultJwtAlgorithm,
options: jwtAlgorithms.map((value) => ({
label: value === 'none' ? 'None' : value,
value,
})),
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'text',
name: 'clientAssertionSecret',
label: 'JWT Secret',
description:
'Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.',
password: true,
optional: true,
multiLine: true,
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'checkbox',
name: 'clientAssertionSecretBase64',
label: 'JWT secret is base64 encoded',
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'text',
@@ -160,7 +227,10 @@ export const plugin: PluginDefinition = {
label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })),
completionOptions: authorizationUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'text',
@@ -169,7 +239,10 @@ export const plugin: PluginDefinition = {
label: 'Access Token URL',
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'banner',
@@ -186,7 +259,8 @@ export const plugin: PluginDefinition = {
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI',
label: 'Redirect URI (can be any valid URL)',
placeholder: 'https://mysite.example.com/oauth/callback',
description:
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
optional: true,
@@ -383,6 +457,11 @@ export const plugin: PluginDefinition = {
{ label: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' },
],
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
hidden:
values.grantType === 'client_credentials' &&
values.clientCredentialsMethod === 'client_assertion',
}),
},
],
},
@@ -484,7 +563,11 @@ export const plugin: PluginDefinition = {
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
clientSecret: stringArg(values, 'clientSecret'),
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
credentialsInBody,

View File

@@ -3,23 +3,30 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { pluralizeCount } from '../lib/pluralize';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
export const moveToWorkspace = createFastMutation({
mutationKey: ['move_workspace'],
mutationFn: async (request: HttpRequest | GrpcRequest | WebsocketRequest) => {
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return;
if (requests.length === 0) return;
const title =
requests.length === 1
? 'Move Request'
: `Move ${pluralizeCount('Request', requests.length)}`;
showDialog({
id: 'change-workspace',
title: 'Move Workspace',
title,
size: 'sm',
render: ({ hide }) => (
<MoveToWorkspaceDialog
onDone={hide}
request={request}
requests={requests}
activeWorkspaceId={activeWorkspaceId}
/>
),

View File

@@ -616,5 +616,16 @@ function KeyValueArg({
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
return inputs.some((i) => !i.hidden);
for (const input of inputs) {
if ('inputs' in input && !hasVisibleInputs(input.inputs)) {
// Has children, but none are visible
return false;
}
if (!input.hidden) {
return true;
}
}
return false;
}

View File

@@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
import { patchModel, workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { pluralizeCount } from '../lib/pluralize';
import { resolvedModelName } from '../lib/resolvedModelName';
import { router } from '../lib/router';
import { showToast } from '../lib/toast';
@@ -12,18 +13,21 @@ import { VStack } from './core/Stacks';
interface Props {
activeWorkspaceId: string;
request: HttpRequest | GrpcRequest | WebsocketRequest;
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
onDone: () => void;
}
export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) {
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
const workspaces = useAtomValue(workspacesAtom);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
return (
<VStack space={4} className="mb-4">
<Select
label="New Workspace"
label="Target Workspace"
name="workspace"
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
@@ -34,27 +38,31 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
/>
<Button
color="primary"
disabled={selectedWorkspaceId === activeWorkspaceId}
disabled={isSameWorkspace}
onClick={async () => {
const patch = {
workspaceId: selectedWorkspaceId,
folderId: null,
};
await patchModel(request, patch);
await Promise.all(requests.map((r) => patchModel(r, patch)));
// Hide after a moment, to give time for request to disappear
// Hide after a moment, to give time for requests to disappear
setTimeout(onDone, 100);
showToast({
id: 'workspace-moved',
message: (
<>
<InlineCode>{resolvedModelName(request)}</InlineCode> moved to{' '}
<InlineCode>
{workspaces.find((w) => w.id === selectedWorkspaceId)?.name ?? 'unknown'}
</InlineCode>
</>
),
message:
requests.length === 1 && requests[0] != null ? (
<>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
</>
) : (
<>
{pluralizeCount('request', requests.length)} moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
</>
),
action: ({ hide }) => (
<Button
size="xs"
@@ -74,7 +82,7 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
});
}}
>
Move
{requests.length === 1 ? 'Move' : `Move ${pluralizeCount('Request', requests.length)}`}
</Button>
</VStack>
);

View File

@@ -278,6 +278,7 @@ function Sidebar({ className }: { className?: string }) {
},
},
'sidebar.selected.duplicate': {
// Higher priority so this takes precedence over model.duplicate (same Meta+d binding)
priority: 10,
enable,
cb: async (items: SidebarModel[]) => {
@@ -290,6 +291,18 @@ function Sidebar({ className }: { className?: string }) {
}
},
},
'sidebar.selected.move': {
enable,
cb: async (items: SidebarModel[]) => {
const requests = items.filter(
(i): i is HttpRequest | GrpcRequest | WebsocketRequest =>
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request'
);
if (requests.length > 0) {
moveToWorkspace.mutate(requests);
}
},
},
'request.send': {
enable,
cb: async (items: SidebarModel[]) => {
@@ -320,6 +333,10 @@ function Sidebar({ className }: { className?: string }) {
const workspaces = jotaiStore.get(workspacesAtom);
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
const requestItems = items.filter(
(i) =>
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request',
);
const initialItems: ContextMenuProps['items'] = [
{
@@ -416,16 +433,13 @@ function Sidebar({ className }: { className?: string }) {
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
},
{
label: 'Move',
label: items.length <= 1 ? 'Move' : `Move ${requestItems.length} Requests`,
hotKeyAction: 'sidebar.selected.move',
hotKeyLabelOnly: true,
leftSlot: <Icon icon="arrow_right_circle" />,
hidden:
workspaces.length <= 1 ||
items.length > 1 ||
child.model === 'folder' ||
child.model === 'workspace',
hidden: workspaces.length <= 1 || requestItems.length === 0 || requestItems.length !== items.length,
onSelect: () => {
if (child.model === 'folder' || child.model === 'workspace') return;
moveToWorkspace.mutate(child);
actions['sidebar.selected.move'].cb(items);
},
},
{

View File

@@ -28,6 +28,7 @@ export type HotkeyAction =
| 'sidebar.filter'
| 'sidebar.selected.delete'
| 'sidebar.selected.duplicate'
| 'sidebar.selected.move'
| 'sidebar.selected.rename'
| 'sidebar.expand_all'
| 'sidebar.collapse_all'
@@ -58,6 +59,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
'sidebar.collapse_all': ['Meta+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'Meta+Backspace'],
'sidebar.selected.duplicate': ['Meta+d'],
'sidebar.selected.move': [],
'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['Meta+b'],
'sidebar.context_menu': ['Control+Enter'],
@@ -87,6 +89,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
'sidebar.collapse_all': ['Control+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'Control+Backspace'],
'sidebar.selected.duplicate': ['Control+d'],
'sidebar.selected.move': [],
'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['Control+b'],
'sidebar.context_menu': ['Alt+Insert'],
@@ -141,6 +144,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'sidebar.collapse_all': 'Collapse All Folders',
'sidebar.selected.delete': 'Delete Selected Sidebar Item',
'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item',
'sidebar.selected.move': 'Move Selected to Workspace',
'sidebar.selected.rename': 'Rename Selected Sidebar Item',
'sidebar.focus': 'Focus or Toggle Sidebar',
'sidebar.context_menu': 'Show Context Menu',