mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat(keyboard): introduce visual keyboard map and shortcut customization
- Added a new `KeyboardMap` component for a visual representation of keyboard shortcuts, allowing users to easily customize their shortcuts with single-modifier support. - Integrated `ShortcutReferencePanel` for editing shortcuts directly within the settings view. - Updated the settings view to include a button for opening the keyboard map dialog, enhancing user experience in managing keyboard shortcuts. - Refactored keyboard shortcut handling to support modifier keys and improve shortcut parsing and formatting.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
const { execSync, spawn } = require('child_process');
|
const { execSync, spawn } = require("child_process");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const os = require('os');
|
const os = require("os");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude CLI Detector
|
* Claude CLI Detector
|
||||||
@@ -21,41 +21,47 @@ class ClaudeCliDetector {
|
|||||||
*/
|
*/
|
||||||
static getUpdatedPathFromShellConfig() {
|
static getUpdatedPathFromShellConfig() {
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || "/bin/bash";
|
||||||
const shellName = path.basename(shell);
|
const shellName = path.basename(shell);
|
||||||
|
|
||||||
// Common shell config files
|
// Common shell config files
|
||||||
const configFiles = [];
|
const configFiles = [];
|
||||||
if (shellName.includes('zsh')) {
|
if (shellName.includes("zsh")) {
|
||||||
configFiles.push(path.join(homeDir, '.zshrc'));
|
configFiles.push(path.join(homeDir, ".zshrc"));
|
||||||
configFiles.push(path.join(homeDir, '.zshenv'));
|
configFiles.push(path.join(homeDir, ".zshenv"));
|
||||||
configFiles.push(path.join(homeDir, '.zprofile'));
|
configFiles.push(path.join(homeDir, ".zprofile"));
|
||||||
} else if (shellName.includes('bash')) {
|
} else if (shellName.includes("bash")) {
|
||||||
configFiles.push(path.join(homeDir, '.bashrc'));
|
configFiles.push(path.join(homeDir, ".bashrc"));
|
||||||
configFiles.push(path.join(homeDir, '.bash_profile'));
|
configFiles.push(path.join(homeDir, ".bash_profile"));
|
||||||
configFiles.push(path.join(homeDir, '.profile'));
|
configFiles.push(path.join(homeDir, ".profile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check common locations
|
// Also check common locations
|
||||||
const commonPaths = [
|
const commonPaths = [
|
||||||
path.join(homeDir, '.local', 'bin'),
|
path.join(homeDir, ".local", "bin"),
|
||||||
path.join(homeDir, '.cargo', 'bin'),
|
path.join(homeDir, ".cargo", "bin"),
|
||||||
'/usr/local/bin',
|
"/usr/local/bin",
|
||||||
'/opt/homebrew/bin',
|
"/opt/homebrew/bin",
|
||||||
path.join(homeDir, 'bin'),
|
path.join(homeDir, "bin"),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Try to extract PATH additions from config files
|
// Try to extract PATH additions from config files
|
||||||
for (const configFile of configFiles) {
|
for (const configFile of configFiles) {
|
||||||
if (fs.existsSync(configFile)) {
|
if (fs.existsSync(configFile)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(configFile, 'utf-8');
|
const content = fs.readFileSync(configFile, "utf-8");
|
||||||
// Look for PATH exports that might include claude installation paths
|
// Look for PATH exports that might include claude installation paths
|
||||||
const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g);
|
const pathMatches = content.match(
|
||||||
|
/export\s+PATH=["']?([^"'\n]+)["']?/g
|
||||||
|
);
|
||||||
if (pathMatches) {
|
if (pathMatches) {
|
||||||
for (const match of pathMatches) {
|
for (const match of pathMatches) {
|
||||||
const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, '');
|
const pathValue = match
|
||||||
const paths = pathValue.split(':').filter(p => p && !p.includes('$'));
|
.replace(/export\s+PATH=["']?/, "")
|
||||||
|
.replace(/["']?$/, "");
|
||||||
|
const paths = pathValue
|
||||||
|
.split(":")
|
||||||
|
.filter((p) => p && !p.includes("$"));
|
||||||
commonPaths.push(...paths);
|
commonPaths.push(...paths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,26 +70,33 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...new Set(commonPaths)]; // Remove duplicates
|
return [...new Set(commonPaths)]; // Remove duplicates
|
||||||
}
|
}
|
||||||
|
|
||||||
static detectClaudeInstallation() {
|
static detectClaudeInstallation() {
|
||||||
console.log('[ClaudeCliDetector] Detecting Claude installation...');
|
console.log("[ClaudeCliDetector] Detecting Claude installation...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Method 1: Check if 'claude' command is in PATH (Unix)
|
// Method 1: Check if 'claude' command is in PATH (Unix)
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim();
|
const claudePath = execSync("which claude 2>/dev/null", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
if (claudePath) {
|
if (claudePath) {
|
||||||
const version = this.getClaudeVersion(claudePath);
|
const version = this.getClaudeVersion(claudePath);
|
||||||
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Found claude at:",
|
||||||
|
claudePath,
|
||||||
|
"version:",
|
||||||
|
version
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,17 +105,26 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Method 2: Check Windows path
|
// Method 2: Check Windows path
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === "win32") {
|
||||||
try {
|
try {
|
||||||
const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
|
const claudePath = execSync("where claude 2>nul", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split("\n")[0];
|
||||||
if (claudePath) {
|
if (claudePath) {
|
||||||
const version = this.getClaudeVersion(claudePath);
|
const version = this.getClaudeVersion(claudePath);
|
||||||
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Found claude at:",
|
||||||
|
claudePath,
|
||||||
|
"version:",
|
||||||
|
version
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,34 +133,49 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Method 3: Check for local installation
|
// Method 3: Check for local installation
|
||||||
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
|
const localClaudePath = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".claude",
|
||||||
|
"local",
|
||||||
|
"claude"
|
||||||
|
);
|
||||||
if (fs.existsSync(localClaudePath)) {
|
if (fs.existsSync(localClaudePath)) {
|
||||||
const version = this.getClaudeVersion(localClaudePath);
|
const version = this.getClaudeVersion(localClaudePath);
|
||||||
console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version);
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Found local claude at:",
|
||||||
|
localClaudePath,
|
||||||
|
"version:",
|
||||||
|
version
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: localClaudePath,
|
path: localClaudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli-local'
|
method: "cli-local",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 4: Check common installation locations (including those from shell config)
|
// Method 4: Check common installation locations (including those from shell config)
|
||||||
const commonPaths = this.getUpdatedPathFromShellConfig();
|
const commonPaths = this.getUpdatedPathFromShellConfig();
|
||||||
const binaryNames = ['claude', 'claude-code'];
|
const binaryNames = ["claude", "claude-code"];
|
||||||
|
|
||||||
for (const basePath of commonPaths) {
|
for (const basePath of commonPaths) {
|
||||||
for (const binaryName of binaryNames) {
|
for (const binaryName of binaryNames) {
|
||||||
const claudePath = path.join(basePath, binaryName);
|
const claudePath = path.join(basePath, binaryName);
|
||||||
if (fs.existsSync(claudePath)) {
|
if (fs.existsSync(claudePath)) {
|
||||||
try {
|
try {
|
||||||
const version = this.getClaudeVersion(claudePath);
|
const version = this.getClaudeVersion(claudePath);
|
||||||
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Found claude at:",
|
||||||
|
claudePath,
|
||||||
|
"version:",
|
||||||
|
version
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File exists but can't get version, might not be executable
|
// File exists but can't get version, might not be executable
|
||||||
@@ -148,29 +185,37 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Method 5: Try to source shell config and check PATH again (for Unix)
|
// Method 5: Try to source shell config and check PATH again (for Unix)
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || "/bin/bash";
|
||||||
const shellName = path.basename(shell);
|
const shellName = path.basename(shell);
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
let sourceCmd = '';
|
let sourceCmd = "";
|
||||||
if (shellName.includes('zsh')) {
|
if (shellName.includes("zsh")) {
|
||||||
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
|
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
|
||||||
} else if (shellName.includes('bash')) {
|
} else if (shellName.includes("bash")) {
|
||||||
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
|
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceCmd) {
|
if (sourceCmd) {
|
||||||
const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
const claudePath = execSync(`bash -c "${sourceCmd}"`, {
|
||||||
if (claudePath && claudePath.startsWith('/')) {
|
encoding: "utf-8",
|
||||||
|
timeout: 2000,
|
||||||
|
}).trim();
|
||||||
|
if (claudePath && claudePath.startsWith("/")) {
|
||||||
const version = this.getClaudeVersion(claudePath);
|
const version = this.getClaudeVersion(claudePath);
|
||||||
console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version);
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Found claude via shell config at:",
|
||||||
|
claudePath,
|
||||||
|
"version:",
|
||||||
|
version
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,21 +224,24 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ClaudeCliDetector] Claude CLI not found');
|
console.log("[ClaudeCliDetector] Claude CLI not found");
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
path: null,
|
path: null,
|
||||||
version: null,
|
version: null,
|
||||||
method: 'none'
|
method: "none",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Error detecting Claude installation:",
|
||||||
|
error
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
path: null,
|
path: null,
|
||||||
version: null,
|
version: null,
|
||||||
method: 'none',
|
method: "none",
|
||||||
error: error.message
|
error: error.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,8 +254,8 @@ class ClaudeCliDetector {
|
|||||||
static getClaudeVersion(claudePath) {
|
static getClaudeVersion(claudePath) {
|
||||||
try {
|
try {
|
||||||
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
|
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
|
||||||
encoding: 'utf-8',
|
encoding: "utf-8",
|
||||||
timeout: 5000
|
timeout: 5000,
|
||||||
}).trim();
|
}).trim();
|
||||||
return version || null;
|
return version || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,10 +274,10 @@ class ClaudeCliDetector {
|
|||||||
* @returns {Object} Authentication status
|
* @returns {Object} Authentication status
|
||||||
*/
|
*/
|
||||||
static getAuthStatus(appCredentialsPath) {
|
static getAuthStatus(appCredentialsPath) {
|
||||||
console.log('[ClaudeCliDetector] Checking auth status...');
|
console.log("[ClaudeCliDetector] Checking auth status...");
|
||||||
|
|
||||||
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey);
|
console.log("[ClaudeCliDetector] Env ANTHROPIC_API_KEY:", !!envApiKey);
|
||||||
|
|
||||||
// Check app's stored credentials
|
// Check app's stored credentials
|
||||||
let storedOAuthToken = null;
|
let storedOAuthToken = null;
|
||||||
@@ -237,38 +285,44 @@ class ClaudeCliDetector {
|
|||||||
|
|
||||||
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
|
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(appCredentialsPath, 'utf-8');
|
const content = fs.readFileSync(appCredentialsPath, "utf-8");
|
||||||
const credentials = JSON.parse(content);
|
const credentials = JSON.parse(content);
|
||||||
storedOAuthToken = credentials.anthropic_oauth_token || null;
|
storedOAuthToken = credentials.anthropic_oauth_token || null;
|
||||||
storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null;
|
storedApiKey =
|
||||||
console.log('[ClaudeCliDetector] App credentials:', {
|
credentials.anthropic || credentials.anthropic_api_key || null;
|
||||||
|
console.log("[ClaudeCliDetector] App credentials:", {
|
||||||
hasOAuthToken: !!storedOAuthToken,
|
hasOAuthToken: !!storedOAuthToken,
|
||||||
hasApiKey: !!storedApiKey
|
hasApiKey: !!storedApiKey,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Error reading app credentials:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine authentication method
|
// Determine authentication method
|
||||||
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let method = 'none';
|
let method = "none";
|
||||||
|
|
||||||
if (storedOAuthToken) {
|
if (storedOAuthToken) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'oauth_token';
|
method = "oauth_token";
|
||||||
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Using stored OAuth token (subscription)"
|
||||||
|
);
|
||||||
} else if (storedApiKey) {
|
} else if (storedApiKey) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'api_key';
|
method = "api_key";
|
||||||
console.log('[ClaudeCliDetector] Using stored API key');
|
console.log("[ClaudeCliDetector] Using stored API key");
|
||||||
} else if (envApiKey) {
|
} else if (envApiKey) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'api_key_env';
|
method = "api_key_env";
|
||||||
console.log('[ClaudeCliDetector] Using environment API key');
|
console.log("[ClaudeCliDetector] Using environment API key");
|
||||||
} else {
|
} else {
|
||||||
console.log('[ClaudeCliDetector] No authentication found');
|
console.log("[ClaudeCliDetector] No authentication found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -276,12 +330,26 @@ class ClaudeCliDetector {
|
|||||||
method,
|
method,
|
||||||
hasStoredOAuthToken: !!storedOAuthToken,
|
hasStoredOAuthToken: !!storedOAuthToken,
|
||||||
hasStoredApiKey: !!storedApiKey,
|
hasStoredApiKey: !!storedApiKey,
|
||||||
hasEnvApiKey: !!envApiKey
|
hasEnvApiKey: !!envApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[ClaudeCliDetector] Auth status result:', result);
|
console.log("[ClaudeCliDetector] Auth status result:", result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Get installation info (installation status only, no auth)
|
||||||
|
* @returns {Object} Installation info with status property
|
||||||
|
*/
|
||||||
|
static getInstallationInfo() {
|
||||||
|
const installation = this.detectClaudeInstallation();
|
||||||
|
return {
|
||||||
|
status: installation.installed ? "installed" : "not_installed",
|
||||||
|
installed: installation.installed,
|
||||||
|
path: installation.path,
|
||||||
|
version: installation.version,
|
||||||
|
method: installation.method,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full status including installation and auth
|
* Get full status including installation and auth
|
||||||
@@ -294,12 +362,12 @@ class ClaudeCliDetector {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: installation.installed ? 'installed' : 'not_installed',
|
status: installation.installed ? "installed" : "not_installed",
|
||||||
installed: installation.installed,
|
installed: installation.installed,
|
||||||
path: installation.path,
|
path: installation.path,
|
||||||
version: installation.version,
|
version: installation.version,
|
||||||
method: installation.method,
|
method: installation.method,
|
||||||
auth
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,9 +377,9 @@ class ClaudeCliDetector {
|
|||||||
*/
|
*/
|
||||||
static getInstallCommands() {
|
static getInstallCommands() {
|
||||||
return {
|
return {
|
||||||
macos: 'curl -fsSL https://claude.ai/install.sh | bash',
|
macos: "curl -fsSL https://claude.ai/install.sh | bash",
|
||||||
windows: 'irm https://claude.ai/install.ps1 | iex',
|
windows: "irm https://claude.ai/install.ps1 | iex",
|
||||||
linux: 'curl -fsSL https://claude.ai/install.sh | bash'
|
linux: "curl -fsSL https://claude.ai/install.sh | bash",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,64 +393,69 @@ class ClaudeCliDetector {
|
|||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
let command, args;
|
let command, args;
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === "win32") {
|
||||||
command = 'powershell';
|
command = "powershell";
|
||||||
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex'];
|
args = ["-Command", "irm https://claude.ai/install.ps1 | iex"];
|
||||||
} else {
|
} else {
|
||||||
command = 'bash';
|
command = "bash";
|
||||||
args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'];
|
args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ClaudeCliDetector] Installing Claude CLI...');
|
console.log("[ClaudeCliDetector] Installing Claude CLI...");
|
||||||
|
|
||||||
const proc = spawn(command, args, {
|
const proc = spawn(command, args, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
shell: false
|
shell: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = '';
|
let output = "";
|
||||||
let errorOutput = '';
|
let errorOutput = "";
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => {
|
proc.stdout.on("data", (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
output += text;
|
output += text;
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({ type: 'stdout', data: text });
|
onProgress({ type: "stdout", data: text });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.stderr.on('data', (data) => {
|
proc.stderr.on("data", (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
errorOutput += text;
|
errorOutput += text;
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({ type: 'stderr', data: text });
|
onProgress({ type: "stderr", data: text });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on("close", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
console.log('[ClaudeCliDetector] Installation completed successfully');
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Installation completed successfully"
|
||||||
|
);
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
output,
|
output,
|
||||||
message: 'Claude CLI installed successfully'
|
message: "Claude CLI installed successfully",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('[ClaudeCliDetector] Installation failed with code:', code);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Installation failed with code:",
|
||||||
|
code
|
||||||
|
);
|
||||||
reject({
|
reject({
|
||||||
success: false,
|
success: false,
|
||||||
error: errorOutput || `Installation failed with code ${code}`,
|
error: errorOutput || `Installation failed with code ${code}`,
|
||||||
output
|
output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on("error", (error) => {
|
||||||
console.error('[ClaudeCliDetector] Installation error:', error);
|
console.error("[ClaudeCliDetector] Installation error:", error);
|
||||||
reject({
|
reject({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
output
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -398,22 +471,22 @@ class ClaudeCliDetector {
|
|||||||
if (!detection.installed) {
|
if (!detection.installed) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Claude CLI is not installed. Please install it first.',
|
error: "Claude CLI is not installed. Please install it first.",
|
||||||
installCommands: this.getInstallCommands()
|
installCommands: this.getInstallCommands(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
command: 'claude setup-token',
|
command: "claude setup-token",
|
||||||
instructions: [
|
instructions: [
|
||||||
'1. Open your terminal',
|
"1. Open your terminal",
|
||||||
'2. Run: claude setup-token',
|
"2. Run: claude setup-token",
|
||||||
'3. Follow the prompts to authenticate',
|
"3. Follow the prompts to authenticate",
|
||||||
'4. Copy the token that is displayed',
|
"4. Copy the token that is displayed",
|
||||||
'5. Paste the token in the field below'
|
"5. Paste the token in the field below",
|
||||||
],
|
],
|
||||||
note: 'This token is from your Claude subscription and allows you to use Claude without API charges.'
|
note: "This token is from your Claude subscription and allows you to use Claude without API charges.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, formatShortcut } from "@/store/app-store";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -722,7 +722,7 @@ export function Sidebar() {
|
|||||||
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
|
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
|
||||||
data-testid="sidebar-toggle-shortcut"
|
data-testid="sidebar-toggle-shortcut"
|
||||||
>
|
>
|
||||||
{shortcuts.toggleSidebar}
|
{formatShortcut(shortcuts.toggleSidebar, true)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -779,8 +779,8 @@ export function Sidebar() {
|
|||||||
data-testid="open-project-button"
|
data-testid="open-project-button"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 shrink-0" />
|
<FolderOpen className="w-4 h-4 shrink-0" />
|
||||||
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
||||||
{shortcuts.openProject}
|
{formatShortcut(shortcuts.openProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -819,10 +819,10 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||||
data-testid="project-picker-shortcut"
|
data-testid="project-picker-shortcut"
|
||||||
>
|
>
|
||||||
{shortcuts.projectPicker}
|
{formatShortcut(shortcuts.projectPicker, true)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
@@ -958,14 +958,14 @@ export function Sidebar() {
|
|||||||
<Undo2 className="w-4 h-4 mr-2" />
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Previous</span>
|
<span className="flex-1">Previous</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{shortcuts.cyclePrevProject}
|
{formatShortcut(shortcuts.cyclePrevProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
||||||
<Redo2 className="w-4 h-4 mr-2" />
|
<Redo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Next</span>
|
<span className="flex-1">Next</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{shortcuts.cycleNextProject}
|
{formatShortcut(shortcuts.cycleNextProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
||||||
@@ -1049,13 +1049,13 @@ export function Sidebar() {
|
|||||||
{item.shortcut && sidebarOpen && (
|
{item.shortcut && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||||
)}
|
)}
|
||||||
data-testid={`shortcut-${item.id}`}
|
data-testid={`shortcut-${item.id}`}
|
||||||
>
|
>
|
||||||
{item.shortcut}
|
{formatShortcut(item.shortcut, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Tooltip for collapsed state */}
|
{/* Tooltip for collapsed state */}
|
||||||
@@ -1115,13 +1115,13 @@ export function Sidebar() {
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||||
isActiveRoute("settings") &&
|
isActiveRoute("settings") &&
|
||||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||||
)}
|
)}
|
||||||
data-testid="shortcut-settings"
|
data-testid="shortcut-settings"
|
||||||
>
|
>
|
||||||
{shortcuts.settings}
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
|
|||||||
640
app/src/components/ui/keyboard-map.tsx
Normal file
640
app/src/components/ui/keyboard-map.tsx
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||||
|
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
// Detect if running on Mac
|
||||||
|
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
// Keyboard layout - US QWERTY
|
||||||
|
const KEYBOARD_ROWS = [
|
||||||
|
// Number row
|
||||||
|
[
|
||||||
|
{ key: "`", label: "`", width: 1 },
|
||||||
|
{ key: "1", label: "1", width: 1 },
|
||||||
|
{ key: "2", label: "2", width: 1 },
|
||||||
|
{ key: "3", label: "3", width: 1 },
|
||||||
|
{ key: "4", label: "4", width: 1 },
|
||||||
|
{ key: "5", label: "5", width: 1 },
|
||||||
|
{ key: "6", label: "6", width: 1 },
|
||||||
|
{ key: "7", label: "7", width: 1 },
|
||||||
|
{ key: "8", label: "8", width: 1 },
|
||||||
|
{ key: "9", label: "9", width: 1 },
|
||||||
|
{ key: "0", label: "0", width: 1 },
|
||||||
|
{ key: "-", label: "-", width: 1 },
|
||||||
|
{ key: "=", label: "=", width: 1 },
|
||||||
|
],
|
||||||
|
// Top letter row
|
||||||
|
[
|
||||||
|
{ key: "Q", label: "Q", width: 1 },
|
||||||
|
{ key: "W", label: "W", width: 1 },
|
||||||
|
{ key: "E", label: "E", width: 1 },
|
||||||
|
{ key: "R", label: "R", width: 1 },
|
||||||
|
{ key: "T", label: "T", width: 1 },
|
||||||
|
{ key: "Y", label: "Y", width: 1 },
|
||||||
|
{ key: "U", label: "U", width: 1 },
|
||||||
|
{ key: "I", label: "I", width: 1 },
|
||||||
|
{ key: "O", label: "O", width: 1 },
|
||||||
|
{ key: "P", label: "P", width: 1 },
|
||||||
|
{ key: "[", label: "[", width: 1 },
|
||||||
|
{ key: "]", label: "]", width: 1 },
|
||||||
|
{ key: "\\", label: "\\", width: 1 },
|
||||||
|
],
|
||||||
|
// Home row
|
||||||
|
[
|
||||||
|
{ key: "A", label: "A", width: 1 },
|
||||||
|
{ key: "S", label: "S", width: 1 },
|
||||||
|
{ key: "D", label: "D", width: 1 },
|
||||||
|
{ key: "F", label: "F", width: 1 },
|
||||||
|
{ key: "G", label: "G", width: 1 },
|
||||||
|
{ key: "H", label: "H", width: 1 },
|
||||||
|
{ key: "J", label: "J", width: 1 },
|
||||||
|
{ key: "K", label: "K", width: 1 },
|
||||||
|
{ key: "L", label: "L", width: 1 },
|
||||||
|
{ key: ";", label: ";", width: 1 },
|
||||||
|
{ key: "'", label: "'", width: 1 },
|
||||||
|
],
|
||||||
|
// Bottom letter row
|
||||||
|
[
|
||||||
|
{ key: "Z", label: "Z", width: 1 },
|
||||||
|
{ key: "X", label: "X", width: 1 },
|
||||||
|
{ key: "C", label: "C", width: 1 },
|
||||||
|
{ key: "V", label: "V", width: 1 },
|
||||||
|
{ key: "B", label: "B", width: 1 },
|
||||||
|
{ key: "N", label: "N", width: 1 },
|
||||||
|
{ key: "M", label: "M", width: 1 },
|
||||||
|
{ key: ",", label: ",", width: 1 },
|
||||||
|
{ key: ".", label: ".", width: 1 },
|
||||||
|
{ key: "/", label: "/", width: 1 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map shortcut names to human-readable labels
|
||||||
|
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||||
|
board: "Kanban Board",
|
||||||
|
agent: "Agent Runner",
|
||||||
|
spec: "Spec Editor",
|
||||||
|
context: "Context",
|
||||||
|
tools: "Agent Tools",
|
||||||
|
settings: "Settings",
|
||||||
|
profiles: "AI Profiles",
|
||||||
|
toggleSidebar: "Toggle Sidebar",
|
||||||
|
addFeature: "Add Feature",
|
||||||
|
addContextFile: "Add Context File",
|
||||||
|
startNext: "Start Next",
|
||||||
|
newSession: "New Session",
|
||||||
|
openProject: "Open Project",
|
||||||
|
projectPicker: "Project Picker",
|
||||||
|
cyclePrevProject: "Prev Project",
|
||||||
|
cycleNextProject: "Next Project",
|
||||||
|
addProfile: "Add Profile",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categorize shortcuts for color coding
|
||||||
|
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||||
|
board: "navigation",
|
||||||
|
agent: "navigation",
|
||||||
|
spec: "navigation",
|
||||||
|
context: "navigation",
|
||||||
|
tools: "navigation",
|
||||||
|
settings: "navigation",
|
||||||
|
profiles: "navigation",
|
||||||
|
toggleSidebar: "ui",
|
||||||
|
addFeature: "action",
|
||||||
|
addContextFile: "action",
|
||||||
|
startNext: "action",
|
||||||
|
newSession: "action",
|
||||||
|
openProject: "action",
|
||||||
|
projectPicker: "action",
|
||||||
|
cyclePrevProject: "action",
|
||||||
|
cycleNextProject: "action",
|
||||||
|
addProfile: "action",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category colors
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
navigation: {
|
||||||
|
bg: "bg-blue-500/20",
|
||||||
|
border: "border-blue-500/50",
|
||||||
|
text: "text-blue-400",
|
||||||
|
label: "Navigation",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
bg: "bg-purple-500/20",
|
||||||
|
border: "border-purple-500/50",
|
||||||
|
text: "text-purple-400",
|
||||||
|
label: "UI Controls",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
bg: "bg-green-500/20",
|
||||||
|
border: "border-green-500/50",
|
||||||
|
text: "text-green-400",
|
||||||
|
label: "Actions",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KeyboardMapProps {
|
||||||
|
onKeySelect?: (key: string) => void;
|
||||||
|
selectedKey?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||||
|
const { keyboardShortcuts } = useAppStore();
|
||||||
|
|
||||||
|
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||||
|
const keyToShortcuts = React.useMemo(() => {
|
||||||
|
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
||||||
|
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||||
|
([shortcutName, shortcutStr]) => {
|
||||||
|
const parsed = parseShortcut(shortcutStr);
|
||||||
|
const normalizedKey = parsed.key.toUpperCase();
|
||||||
|
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||||
|
if (!map[normalizedKey]) {
|
||||||
|
map[normalizedKey] = [];
|
||||||
|
}
|
||||||
|
map[normalizedKey].push({ name: shortcutName, hasModifiers });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return map;
|
||||||
|
}, [keyboardShortcuts]);
|
||||||
|
|
||||||
|
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||||
|
const normalizedKey = keyDef.key.toUpperCase();
|
||||||
|
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||||
|
const shortcuts = shortcutInfos.map(s => s.name);
|
||||||
|
const isBound = shortcuts.length > 0;
|
||||||
|
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||||
|
const isModified = shortcuts.some(
|
||||||
|
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||||
|
);
|
||||||
|
const hasModifierShortcuts = shortcutInfos.some(s => s.hasModifiers);
|
||||||
|
|
||||||
|
// Get category for coloring (use first shortcut's category if multiple)
|
||||||
|
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||||
|
const colors = category ? CATEGORY_COLORS[category] : null;
|
||||||
|
|
||||||
|
const keyElement = (
|
||||||
|
<button
|
||||||
|
key={keyDef.key}
|
||||||
|
onClick={() => onKeySelect?.(keyDef.key)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||||
|
"h-12 min-w-[2.75rem] py-1",
|
||||||
|
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||||
|
// Base styles
|
||||||
|
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||||
|
// Bound key styles
|
||||||
|
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||||
|
// Selected state
|
||||||
|
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||||
|
// Modified indicator
|
||||||
|
isModified && "ring-1 ring-yellow-500/50"
|
||||||
|
)}
|
||||||
|
data-testid={`keyboard-key-${keyDef.key}`}
|
||||||
|
>
|
||||||
|
{/* Key label - always at top */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-mono font-bold leading-none",
|
||||||
|
isBound && colors ? colors.text : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{keyDef.label}
|
||||||
|
</span>
|
||||||
|
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||||
|
isBound && shortcuts.length > 0
|
||||||
|
? (colors ? colors.text : "text-muted-foreground")
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBound && shortcuts.length > 0
|
||||||
|
? (shortcuts.length === 1
|
||||||
|
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
|
||||||
|
: `${shortcuts.length}x`)
|
||||||
|
: "\u00A0" // Non-breaking space to maintain height
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{isModified && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap in tooltip if bound
|
||||||
|
if (isBound) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={keyDef.key}>
|
||||||
|
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
const shortcutStr = keyboardShortcuts[shortcut];
|
||||||
|
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||||
|
return (
|
||||||
|
<div key={shortcut} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full",
|
||||||
|
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
|
||||||
|
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||||
|
{displayShortcut}
|
||||||
|
</kbd>
|
||||||
|
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||||
|
<span className="text-xs text-yellow-400">(custom)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||||
|
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 rounded border",
|
||||||
|
colors.bg,
|
||||||
|
colors.border
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={colors.text}>{colors.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||||
|
<span className="text-muted-foreground">Available</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
<span className="text-yellow-400">Modified</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard layout */}
|
||||||
|
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||||
|
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||||
|
{row.map(renderKey)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||||
|
configured
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{Object.keys(keyToShortcuts).length}
|
||||||
|
</strong>{" "}
|
||||||
|
keys in use
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||||
|
</strong>{" "}
|
||||||
|
keys available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full shortcut reference panel with editing capability
|
||||||
|
interface ShortcutReferencePanelProps {
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
|
||||||
|
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
|
||||||
|
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
|
||||||
|
const [keyValue, setKeyValue] = React.useState("");
|
||||||
|
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||||
|
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const groupedShortcuts = React.useMemo(() => {
|
||||||
|
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||||
|
navigation: [],
|
||||||
|
ui: [],
|
||||||
|
action: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
(Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||||
|
([shortcut, category]) => {
|
||||||
|
groups[category].push({
|
||||||
|
key: shortcut,
|
||||||
|
label: SHORTCUT_LABELS[shortcut],
|
||||||
|
value: keyboardShortcuts[shortcut],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [keyboardShortcuts]);
|
||||||
|
|
||||||
|
// Build the full shortcut string from key + modifiers
|
||||||
|
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
|
||||||
|
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
|
||||||
|
if (mods.shift) parts.push("Shift");
|
||||||
|
parts.push(key.toUpperCase());
|
||||||
|
return parts.join("+");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check for conflicts with other shortcuts
|
||||||
|
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||||
|
const conflict = Object.entries(keyboardShortcuts).find(
|
||||||
|
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
|
||||||
|
);
|
||||||
|
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
|
||||||
|
}, [keyboardShortcuts]);
|
||||||
|
|
||||||
|
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||||
|
const currentValue = keyboardShortcuts[key];
|
||||||
|
const parsed = parseShortcut(currentValue);
|
||||||
|
setEditingShortcut(key);
|
||||||
|
setKeyValue(parsed.key);
|
||||||
|
setModifiers({
|
||||||
|
shift: parsed.shift || false,
|
||||||
|
cmdCtrl: parsed.cmdCtrl || false,
|
||||||
|
alt: parsed.alt || false,
|
||||||
|
});
|
||||||
|
setShortcutError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveShortcut = () => {
|
||||||
|
if (!editingShortcut || shortcutError || !keyValue) return;
|
||||||
|
const shortcutStr = buildShortcutString(keyValue, modifiers);
|
||||||
|
setKeyboardShortcut(editingShortcut, shortcutStr);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setKeyValue("");
|
||||||
|
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||||
|
setShortcutError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setKeyValue("");
|
||||||
|
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||||
|
setShortcutError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => {
|
||||||
|
setKeyValue(value);
|
||||||
|
// Check for conflicts with full shortcut string
|
||||||
|
if (!value) {
|
||||||
|
setShortcutError("Key cannot be empty");
|
||||||
|
} else {
|
||||||
|
const shortcutStr = buildShortcutString(value, modifiers);
|
||||||
|
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||||
|
if (conflictLabel) {
|
||||||
|
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||||
|
} else {
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
|
||||||
|
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
|
||||||
|
const newModifiers = checked
|
||||||
|
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
|
||||||
|
: { ...modifiers, [modifier]: false };
|
||||||
|
|
||||||
|
setModifiers(newModifiers);
|
||||||
|
|
||||||
|
// Recheck for conflicts
|
||||||
|
if (keyValue) {
|
||||||
|
const shortcutStr = buildShortcutString(keyValue, newModifiers);
|
||||||
|
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||||
|
if (conflictLabel) {
|
||||||
|
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||||
|
} else {
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !shortcutError && keyValue) {
|
||||||
|
handleSaveShortcut();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
handleCancelEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetShortcut = (key: keyof KeyboardShortcuts) => {
|
||||||
|
setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||||
|
{editable && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetKeyboardShortcuts()}
|
||||||
|
className="gap-2 text-xs"
|
||||||
|
data-testid="reset-all-shortcuts-button"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Reset All to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||||
|
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||||
|
return (
|
||||||
|
<div key={category} className="space-y-2">
|
||||||
|
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||||
|
{colors.label}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{shortcuts.map(({ key, label, value }) => {
|
||||||
|
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||||
|
const isEditing = editingShortcut === key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||||
|
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||||
|
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||||
|
data-testid={`shortcut-row-${key}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Modifier checkboxes */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-cmd-${key}`}
|
||||||
|
checked={modifiers.cmdCtrl}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
{isMac ? "⌘" : "Ctrl"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-alt-${key}`}
|
||||||
|
checked={modifiers.alt}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
{isMac ? "⌥" : "Alt"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-shift-${key}`}
|
||||||
|
checked={modifiers.shift}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
⇧
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">+</span>
|
||||||
|
<Input
|
||||||
|
value={keyValue}
|
||||||
|
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
"w-12 h-7 text-center font-mono text-xs uppercase",
|
||||||
|
shortcutError && "border-red-500 focus-visible:ring-red-500"
|
||||||
|
)}
|
||||||
|
placeholder="Key"
|
||||||
|
maxLength={1}
|
||||||
|
autoFocus
|
||||||
|
data-testid={`edit-shortcut-input-${key}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSaveShortcut();
|
||||||
|
}}
|
||||||
|
disabled={!!shortcutError || !keyValue}
|
||||||
|
data-testid={`save-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`cancel-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<kbd
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs font-mono rounded border",
|
||||||
|
colors.bg,
|
||||||
|
colors.border,
|
||||||
|
colors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatShortcut(value, true)}
|
||||||
|
</kbd>
|
||||||
|
{isModified && editable && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleResetShortcut(key);
|
||||||
|
}}
|
||||||
|
data-testid={`reset-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isModified && !editable && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
)}
|
||||||
|
{editable && !isModified && (
|
||||||
|
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -41,9 +40,9 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Info,
|
Info,
|
||||||
|
Keyboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,6 +52,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
|
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
|
||||||
// Navigation items for the side panel
|
// Navigation items for the side panel
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -84,9 +85,6 @@ export function SettingsView() {
|
|||||||
setShowProfilesOnly,
|
setShowProfilesOnly,
|
||||||
currentProject,
|
currentProject,
|
||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
keyboardShortcuts,
|
|
||||||
setKeyboardShortcut,
|
|
||||||
resetKeyboardShortcuts,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Compute the effective theme for the current project
|
// Compute the effective theme for the current project
|
||||||
@@ -108,6 +106,7 @@ export function SettingsView() {
|
|||||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
|
|
||||||
const [testResult, setTestResult] = useState<{
|
const [testResult, setTestResult] = useState<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -155,6 +154,7 @@ export function SettingsView() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [activeSection, setActiveSection] = useState("api-keys");
|
const [activeSection, setActiveSection] = useState("api-keys");
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
||||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
||||||
const [apiKeyStatus, setApiKeyStatus] = useState<{
|
const [apiKeyStatus, setApiKeyStatus] = useState<{
|
||||||
@@ -1608,376 +1608,35 @@ export function SettingsView() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Customize keyboard shortcuts for navigation and actions. Click
|
Customize keyboard shortcuts for navigation and actions using the visual keyboard map.
|
||||||
on any shortcut to edit it.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6">
|
||||||
{/* Navigation Shortcuts */}
|
{/* Centered message directing to keyboard map */}
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="relative">
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<Keyboard className="w-16 h-16 text-brand-500/30" />
|
||||||
Navigation
|
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Use the Visual Keyboard Map
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<p className="text-sm text-muted-foreground">
|
||||||
variant="ghost"
|
Click the "View Keyboard Map" button above to customize your keyboard shortcuts.
|
||||||
size="sm"
|
The visual interface shows all available keys and lets you easily edit shortcuts with
|
||||||
onClick={() => resetKeyboardShortcuts()}
|
single-modifier restrictions.
|
||||||
className="text-xs h-7"
|
|
||||||
data-testid="reset-shortcuts-button"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
|
||||||
Reset All to Defaults
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" },
|
|
||||||
{ key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" },
|
|
||||||
{ key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" },
|
|
||||||
{ key: "context" as keyof KeyboardShortcuts, label: "Context" },
|
|
||||||
{ key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" },
|
|
||||||
{ key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" },
|
|
||||||
{ key: "settings" as keyof KeyboardShortcuts, label: "Settings" },
|
|
||||||
].map(({ key, label }) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editingShortcut === key ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
value={shortcutValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value.toUpperCase();
|
|
||||||
setShortcutValue(value);
|
|
||||||
// Check for conflicts
|
|
||||||
const conflict = Object.entries(keyboardShortcuts).find(
|
|
||||||
([k, v]) => k !== key && v.toUpperCase() === value
|
|
||||||
);
|
|
||||||
if (conflict) {
|
|
||||||
setShortcutError(`Already used by ${conflict[0]}`);
|
|
||||||
} else {
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 h-8 text-center font-mono"
|
|
||||||
placeholder="Key"
|
|
||||||
maxLength={2}
|
|
||||||
autoFocus
|
|
||||||
data-testid={`edit-shortcut-${key}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
if (!shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!!shortcutError || !shortcutValue}
|
|
||||||
data-testid={`save-shortcut-${key}`}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
data-testid={`cancel-shortcut-${key}`}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(key);
|
|
||||||
setShortcutValue(keyboardShortcuts[key]);
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
|
||||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
|
||||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
|
||||||
)}
|
|
||||||
data-testid={`shortcut-${key}`}
|
|
||||||
>
|
|
||||||
{keyboardShortcuts[key]}
|
|
||||||
</button>
|
|
||||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
|
||||||
<span className="text-xs text-brand-400">(modified)</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{shortcutError && (
|
|
||||||
<p className="text-xs text-red-400">{shortcutError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* UI Shortcuts */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
|
||||||
UI Controls
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
|
|
||||||
].map(({ key, label }) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editingShortcut === key ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
value={shortcutValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setShortcutValue(value);
|
|
||||||
// Check for conflicts
|
|
||||||
const conflict = Object.entries(keyboardShortcuts).find(
|
|
||||||
([k, v]) => k !== key && v === value
|
|
||||||
);
|
|
||||||
if (conflict) {
|
|
||||||
setShortcutError(`Already used by ${conflict[0]}`);
|
|
||||||
} else {
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 h-8 text-center font-mono"
|
|
||||||
placeholder="Key"
|
|
||||||
maxLength={2}
|
|
||||||
autoFocus
|
|
||||||
data-testid={`edit-shortcut-${key}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
if (!shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!!shortcutError || !shortcutValue}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(key);
|
|
||||||
setShortcutValue(keyboardShortcuts[key]);
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
|
||||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
|
||||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
|
||||||
)}
|
|
||||||
data-testid={`shortcut-${key}`}
|
|
||||||
>
|
|
||||||
{keyboardShortcuts[key]}
|
|
||||||
</button>
|
|
||||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
|
||||||
<span className="text-xs text-brand-400">(modified)</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Shortcuts */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
|
||||||
Actions
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" },
|
|
||||||
{ key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" },
|
|
||||||
{ key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" },
|
|
||||||
{ key: "newSession" as keyof KeyboardShortcuts, label: "New Session" },
|
|
||||||
{ key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" },
|
|
||||||
{ key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" },
|
|
||||||
{ key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" },
|
|
||||||
{ key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" },
|
|
||||||
{ key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" },
|
|
||||||
].map(({ key, label }) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editingShortcut === key ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
value={shortcutValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value.toUpperCase();
|
|
||||||
setShortcutValue(value);
|
|
||||||
// Check for conflicts
|
|
||||||
const conflict = Object.entries(keyboardShortcuts).find(
|
|
||||||
([k, v]) => k !== key && v.toUpperCase() === value
|
|
||||||
);
|
|
||||||
if (conflict) {
|
|
||||||
setShortcutError(`Already used by ${conflict[0]}`);
|
|
||||||
} else {
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 h-8 text-center font-mono"
|
|
||||||
placeholder="Key"
|
|
||||||
maxLength={2}
|
|
||||||
autoFocus
|
|
||||||
data-testid={`edit-shortcut-${key}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
if (!shortcutError && shortcutValue) {
|
|
||||||
setKeyboardShortcut(key, shortcutValue);
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!!shortcutError || !shortcutValue}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(null);
|
|
||||||
setShortcutValue("");
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingShortcut(key);
|
|
||||||
setShortcutValue(keyboardShortcuts[key]);
|
|
||||||
setShortcutError(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
|
||||||
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
|
||||||
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
|
||||||
)}
|
|
||||||
data-testid={`shortcut-${key}`}
|
|
||||||
>
|
|
||||||
{keyboardShortcuts[key]}
|
|
||||||
</button>
|
|
||||||
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
|
||||||
<span className="text-xs text-brand-400">(modified)</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Information */}
|
|
||||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<p className="font-medium text-blue-400">
|
|
||||||
About Keyboard Shortcuts
|
|
||||||
</p>
|
|
||||||
<p className="text-blue-400/80 text-xs mt-1">
|
|
||||||
Shortcuts won't trigger when typing in input fields. Use
|
|
||||||
single keys (A-Z, 0-9) or special keys like ` (backtick).
|
|
||||||
Changes take effect immediately.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setShowKeyboardMapDialog(true)}
|
||||||
|
className="gap-2 mt-4"
|
||||||
|
>
|
||||||
|
<Keyboard className="w-5 h-5" />
|
||||||
|
Open Keyboard Map
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2169,6 +1828,44 @@ export function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Map Dialog */}
|
||||||
|
<Dialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog}>
|
||||||
|
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Keyboard className="w-5 h-5 text-brand-500" />
|
||||||
|
Keyboard Shortcut Map
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts.
|
||||||
|
Click on any shortcut below to edit it.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 py-4">
|
||||||
|
{/* Visual Keyboard Map */}
|
||||||
|
<KeyboardMap />
|
||||||
|
|
||||||
|
{/* Shortcut Reference - Editable */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
All Shortcuts Reference (Click to Edit)
|
||||||
|
</h3>
|
||||||
|
<ShortcutReferencePanel editable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowKeyboardMapDialog(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
{/* Delete Project Confirmation Dialog */}
|
||||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<DialogContent className="bg-popover border-border max-w-md">
|
<DialogContent className="bg-popover border-border max-w-md">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, parseShortcut } from "@/store/app-store";
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
key: string;
|
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
|
||||||
action: () => void;
|
action: () => void;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,44 @@ function isInputFocused(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a keyboard event matches a shortcut definition
|
||||||
|
*/
|
||||||
|
function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean {
|
||||||
|
const shortcut = parseShortcut(shortcutStr);
|
||||||
|
|
||||||
|
// Check if the key matches (case-insensitive)
|
||||||
|
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check modifier keys
|
||||||
|
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||||
|
const shiftPressed = event.shiftKey;
|
||||||
|
const altPressed = event.altKey;
|
||||||
|
|
||||||
|
// If shortcut requires cmdCtrl, it must be pressed
|
||||||
|
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
|
||||||
|
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
|
||||||
|
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
|
||||||
|
|
||||||
|
// If shortcut requires shift, it must be pressed
|
||||||
|
if (shortcut.shift && !shiftPressed) return false;
|
||||||
|
// If shortcut doesn't require shift, it shouldn't be pressed
|
||||||
|
if (!shortcut.shift && shiftPressed) return false;
|
||||||
|
|
||||||
|
// If shortcut requires alt, it must be pressed
|
||||||
|
if (shortcut.alt && !altPressed) return false;
|
||||||
|
// If shortcut doesn't require alt, it shouldn't be pressed
|
||||||
|
if (!shortcut.alt && altPressed) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to manage keyboard shortcuts
|
* Hook to manage keyboard shortcuts
|
||||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
||||||
|
* Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option
|
||||||
*/
|
*/
|
||||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't trigger if any modifier keys are pressed (except for specific combos we want)
|
|
||||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching shortcut
|
// Find matching shortcut
|
||||||
const matchingShortcut = shortcuts.find(
|
const matchingShortcut = shortcuts.find(
|
||||||
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
|
(shortcut) => matchesShortcut(event, shortcut.key)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingShortcut) {
|
if (matchingShortcut) {
|
||||||
|
|||||||
@@ -37,7 +37,68 @@ export interface ApiKeys {
|
|||||||
openai: string;
|
openai: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcut with optional modifiers
|
||||||
|
export interface ShortcutKey {
|
||||||
|
key: string; // The main key (e.g., "K", "N", "1")
|
||||||
|
shift?: boolean; // Shift key modifier
|
||||||
|
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||||
|
alt?: boolean; // Alt/Option key modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to parse shortcut string to ShortcutKey object
|
||||||
|
export function parseShortcut(shortcut: string): ShortcutKey {
|
||||||
|
const parts = shortcut.split("+").map(p => p.trim());
|
||||||
|
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const modifier = parts[i].toLowerCase();
|
||||||
|
if (modifier === "shift") result.shift = true;
|
||||||
|
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
|
||||||
|
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format ShortcutKey to display string
|
||||||
|
export function formatShortcut(shortcut: string, forDisplay = false): string {
|
||||||
|
const parsed = parseShortcut(shortcut);
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Improved OS detection
|
||||||
|
let platform: 'darwin' | 'win32' | 'linux' = 'linux';
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
const p = navigator.platform.toLowerCase();
|
||||||
|
if (p.includes('mac')) platform = 'darwin';
|
||||||
|
else if (p.includes('win')) platform = 'win32';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary modifier - OS-specific
|
||||||
|
if (parsed.cmdCtrl) {
|
||||||
|
if (forDisplay) {
|
||||||
|
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
|
||||||
|
} else {
|
||||||
|
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt/Option
|
||||||
|
if (parsed.alt) {
|
||||||
|
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift
|
||||||
|
if (parsed.shift) {
|
||||||
|
parts.push(forDisplay ? '⇧' : 'Shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(parsed.key.toUpperCase());
|
||||||
|
|
||||||
|
// Add spacing when displaying symbols
|
||||||
|
return parts.join(forDisplay ? " " : "+");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
|
||||||
export interface KeyboardShortcuts {
|
export interface KeyboardShortcuts {
|
||||||
// Navigation shortcuts
|
// Navigation shortcuts
|
||||||
board: string;
|
board: string;
|
||||||
@@ -47,10 +108,10 @@ export interface KeyboardShortcuts {
|
|||||||
tools: string;
|
tools: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
profiles: string;
|
profiles: string;
|
||||||
|
|
||||||
// UI shortcuts
|
// UI shortcuts
|
||||||
toggleSidebar: string;
|
toggleSidebar: string;
|
||||||
|
|
||||||
// Action shortcuts
|
// Action shortcuts
|
||||||
addFeature: string;
|
addFeature: string;
|
||||||
addContextFile: string;
|
addContextFile: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user