Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(keybindings): adding inline shortcut #3895

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
4 changes: 4 additions & 0 deletions clients/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ChatSidePanelProvider } from "./chat/sidePanel";
import { Commands } from "./commands";
import { init as initFindFiles } from "./findFiles";
import { CodeActions } from "./CodeActions";
import { VSCodeKeyBindingManager } from "./keybindings";

const logger = getLogger();
let clientRef: Client | undefined = undefined;
Expand Down Expand Up @@ -52,6 +53,9 @@ export async function activate(context: ExtensionContext) {
);
commands.register();

// init keybinding manager
VSCodeKeyBindingManager.getInstance().init();

// Register code actions
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ /* eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error */
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ // @ts-ignore noUnusedLocals
Expand Down
146 changes: 146 additions & 0 deletions clients/vscode/src/keybindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { readFile } from "fs-extra";
import path from "path";
import { getLogger } from "./logger";
import * as vscode from "vscode";
import { isBrowser } from "./env";
import pkg from "../package.json";
interface KeyBinding {
key: string;
command: string;
when?: string;
}

const logger = getLogger("VSCodeKeyBindingManager");
const isMac = isBrowser
? navigator.userAgent.toLowerCase().includes("mac")
: process.platform.toLowerCase().includes("darwin");

export class VSCodeKeyBindingManager {
// Singleton instance to manage keybindings.
private static instance: VSCodeKeyBindingManager | null = null;

/**
* Returns the singleton instance.
*/
public static getInstance(): VSCodeKeyBindingManager {
if (!VSCodeKeyBindingManager.instance) {
VSCodeKeyBindingManager.instance = new VSCodeKeyBindingManager();
}
return VSCodeKeyBindingManager.instance;
}

// Cached keybindings loaded during extension startup.
private keybindings: KeyBinding[] | null = null;

/**
* Initializes the keybinding manager.
* This method should be called once during extension startup.
* It reads the keybindings.json file once to avoid additional overhead.
*/
public async init(): Promise<void> {
this.keybindings = await this.readKeyBindings();
}

/**
* Reads the keybindings file and returns the parsed keybindings.
*/
async readKeyBindings(): Promise<KeyBinding[] | null> {
try {
let rawData: string;
if (isBrowser) {
rawData = await this.readWorkspaceKeybindings();
} else {
const isMac = process.platform === "darwin";
const keybindingsPath = isMac
? path.join(process.env["HOME"] ?? "~", "Library", "Application Support", "Code", "User", "keybindings.json")
: path.join(process.env["APPDATA"] || process.env["HOME"] + "/.config", "Code", "User", "keybindings.json");
rawData = await readFile(keybindingsPath, "utf8");
}
return this.parseKeybindings(rawData);
} catch (error) {
logger.error("Error reading keybindings:", error);
return null;
}
}

/**
* Reads keybindings.json from the workspace folder (for browser environments).
*/
async readWorkspaceKeybindings(): Promise<string> {
const workspace = vscode.workspace.workspaceFolders?.[0];
if (!workspace) {
throw new Error("No workspace found");
}
const keybindingsUri = vscode.Uri.joinPath(workspace.uri, ".vscode", "keybindings.json");
const data = await vscode.workspace.fs.readFile(keybindingsUri);
return Buffer.from(data).toString("utf8");
}

/**
* Parses the raw keybindings JSON data and filters out invalid entries.
*/
parseKeybindings(data: string): KeyBinding[] {
const cleanData = data
.split("\n")
.map((line) => line.trim())
.filter((line) => !line.startsWith("//"))
.join("\n");

try {
const parsed = JSON.parse(cleanData) as KeyBinding[];
return parsed.filter((binding) => {
const isValid = binding.key && binding.command;
if (!isValid) {
logger.warn("Invalid keybinding found:", binding);
}
return isValid;
});
} catch (error) {
logger.error("Error parsing keybindings JSON:", error);
return [];
}
}

/**
* Checks if the specified command is rebound by verifying its presence in the cached keybindings.
*/
isCommandRebound(command: string): boolean {
return this.keybindings ? this.keybindings.some((binding) => binding.command === command) : false;
}

/**
* Retrieves the keybinding for a specified command from the cached keybindings.
* Returns the key if found and not disabled; otherwise returns null.
*/
getCommandBinding(command: string): string | null {
if (!this.keybindings) {
return null;
}
const binding = this.keybindings.find((b) => b.command === command && !b.command.startsWith("-"));
return binding?.key || null;
}

/**
* Checks if a command is disabled by verifying if a keybinding with a '-' prefix exists.
*/
isKeyBindingDisabled(command: string): boolean {
return this.keybindings ? this.keybindings.some((b) => b.command === `-${command}`) : false;
}
}

export function getPackageCommandBinding(command: string): string {
try {
if (!pkg.contributes.keybindings || !Array.isArray(pkg.contributes.keybindings)) {
logger.warn("No keybindings found in package.json");
return "";
}
const binding = pkg.contributes.keybindings.find((b) => b.command === command);
if (!binding) {
return "";
}
return isMac && binding.mac ? binding.mac : binding.key;
} catch (error) {
logger.error("Error reading package.json keybindings:", error);
return "";
}
}
47 changes: 47 additions & 0 deletions clients/vscode/src/lsp/CodeLensMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
import { CodeLensMiddleware as VscodeLspCodeLensMiddleware, ProvideCodeLensesSignature } from "vscode-languageclient";
import { CodeLens as TabbyCodeLens } from "tabby-agent";
import { findTextEditor } from "./vscodeWindowUtils";
import { isBrowser } from "../env";
import { getPackageCommandBinding, VSCodeKeyBindingManager } from "../keybindings";

type CodeLens = VscodeCodeLens & TabbyCodeLens;

Expand Down Expand Up @@ -102,6 +104,8 @@ export class CodeLensMiddleware implements VscodeLspCodeLensMiddleware {
if (!codeLens.data || codeLens.data.type !== "previewChanges") {
return codeLens;
}
this.addShortcut(codeLens);

const decorationRange = new Range(
codeLens.range.start.line,
codeLens.range.start.character,
Expand Down Expand Up @@ -148,6 +152,28 @@ export class CodeLensMiddleware implements VscodeLspCodeLensMiddleware {
ranges.push(range);
editor.setDecorations(decorationType, ranges);
}
private addShortcut(codeLens: CodeLens) {
const action = codeLens.command?.arguments?.[0]?.action;
if (!action) return;

let commandId: string;
if (action === "accept") {
commandId = "tabby.chat.edit.accept";
} else if (action === "discard") {
commandId = "tabby.chat.edit.discard";
} else {
return;
}
const binding =
VSCodeKeyBindingManager.getInstance().getCommandBinding(commandId) || getPackageCommandBinding(commandId);

const formattedShortcut = binding ? formatShortcut(binding) : "";
const shortcutText = isBrowser ? "" : formattedShortcut ? ` (${formattedShortcut})` : "";

if (!codeLens.command) return;

codeLens.command.title += shortcutText;
}

private removeDecorations(editor: TextEditor) {
if (this.decorationMap.has(editor)) {
Expand All @@ -166,3 +192,24 @@ export class CodeLensMiddleware implements VscodeLspCodeLensMiddleware {
editorsToRemove.forEach((editor) => this.decorationMap.delete(editor));
}
}

const formatShortcut = (shortcut: string): string => {
return shortcut
.split("+")
.map((key) => {
const capitalizedKey = key.charAt(0).toUpperCase() + key.slice(1).toLowerCase();
switch (key.toLowerCase()) {
case "cmd":
return "⌘";
case "ctrl":
return "Ctrl";
case "alt":
return "Alt";
case "shift":
return "Shift";
default:
return capitalizedKey;
}
})
.join("+");
};
Loading