Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)
- `editor.openFavorite`: open current project/worktree in the last-used editor
- `script.{id}.run`: run a project script by id (for example `script.test.run`)

### Key Syntax

Expand Down
82 changes: 80 additions & 2 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { assert, describe, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { assert, afterEach, describe, expect, it, vi } from "vitest";

import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
import {
compileResolvedKeybindingRule,
parseKeybindingShortcut,
upsertKeybindingRule,
} from "./keybindings";

describe("server keybindings", () => {
const tempDirs: string[] = [];
const logger = {
warn: vi.fn(),
};

function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}

afterEach(() => {
vi.restoreAllMocks();
logger.warn.mockReset();
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("parses shortcuts including plus key", () => {
assert.deepEqual(parseKeybindingShortcut("mod+j"), {
key: "j",
Expand Down Expand Up @@ -66,4 +92,56 @@ describe("server keybindings", () => {
}),
);
});

it("upserts custom keybindings to ~/.t3/keybindings.json", () => {
const fakeHome = makeTempDir("t3code-keybindings-upsert-");
const configDir = path.join(fakeHome, ".t3");
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, "keybindings.json");
fs.writeFileSync(
configPath,
JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
"utf8",
);
vi.spyOn(os, "homedir").mockReturnValue(fakeHome);

const resolved = upsertKeybindingRule(logger, {
key: "mod+shift+r",
command: "script.run-tests.run",
});
const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
key: string;
command: string;
}>;

expect(persisted).toEqual([
{ key: "mod+j", command: "terminal.toggle" },
{ key: "mod+shift+r", command: "script.run-tests.run" },
]);
expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true);
});

it("replaces existing custom keybinding for the same command", () => {
const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-");
const configDir = path.join(fakeHome, ".t3");
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, "keybindings.json");
fs.writeFileSync(
configPath,
JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]),
"utf8",
);
vi.spyOn(os, "homedir").mockReturnValue(fakeHome);

upsertKeybindingRule(logger, {
key: "mod+shift+r",
command: "script.run-tests.run",
});

const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
key: string;
command: string;
}>;
expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]);
});
});
120 changes: 80 additions & 40 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,51 +274,35 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
}

const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");
const MAX_KEYBINDINGS = 256;

function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
if (custom.length === 0) {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}

const overriddenCommands = new Set(custom.map((binding) => binding.command));
const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
(binding) => !overriddenCommands.has(binding.command),
);
const merged = [...retainedDefaults, ...custom];

if (merged.length <= 256) {
return merged;
}

// Keep the latest rules when the config exceeds max size; later rules have higher precedence.
return merged.slice(-256);
function resolveKeybindingsConfigPath(): string {
return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);
}

export function loadResolvedKeybindingsConfig(
logger: KeybindingsLogger,
): ResolvedKeybindingsConfig {
const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig {
const configPath = resolveKeybindingsConfigPath();
try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
logger.warn("ignoring keybindings config with unsupported format; expected array", {
path: configPath,
});
return [...DEFAULT_RESOLVED_KEYBINDINGS];
return [];
}

const sanitized: ResolvedKeybindingsConfig = [];
const sanitized: KeybindingsConfig = [];
let invalidEntries = 0;
for (const entry of parsed) {
const result = keybindingRuleSchema.safeParse(entry);
if (result.success) {
const compiled = compileResolvedKeybindingRule(result.data);
if (!compiled) {
if (!compileResolvedKeybindingRule(result.data)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
invalidEntries += 1;
continue;
}
sanitized.push(compiled);
sanitized.push(result.data);
continue;
}
invalidEntries += 1;
Expand All @@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig(
totalEntries: parsed.length,
});
}
const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
const mergedBeforeCapLength =
DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
.length + sanitized.length;
const merged = mergeWithDefaultKeybindings(sanitized);
if (mergedBeforeCapLength > 256) {
logger.warn("truncating merged keybindings config to max entries", {
path: configPath,
maxEntries: 256,
});
}
return merged;
return sanitized;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
return [];
}
logger.warn("ignoring malformed keybindings config", {
path: configPath,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}

function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
if (custom.length === 0) {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}

const overriddenCommands = new Set(custom.map((binding) => binding.command));
const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
(binding) => !overriddenCommands.has(binding.command),
);
const merged = [...retainedDefaults, ...custom];

if (merged.length <= MAX_KEYBINDINGS) {
return merged;
}

// Keep the latest rules when the config exceeds max size; later rules have higher precedence.
return merged.slice(-MAX_KEYBINDINGS);
}

export function loadResolvedKeybindingsConfig(
logger: KeybindingsLogger,
): ResolvedKeybindingsConfig {
const customConfig = loadCustomKeybindingsConfig(logger);
const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig);
const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command));
const mergedBeforeCapLength =
DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
.length + compiledCustomConfig.length;
const merged = mergeWithDefaultKeybindings(compiledCustomConfig);
if (mergedBeforeCapLength > MAX_KEYBINDINGS) {
logger.warn("truncating merged keybindings config to max entries", {
path: resolveKeybindingsConfigPath(),
maxEntries: MAX_KEYBINDINGS,
});
}
return merged;
}

export function upsertKeybindingRule(
logger: KeybindingsLogger,
rawRule: unknown,
): ResolvedKeybindingsConfig {
const rule = keybindingRuleSchema.parse(rawRule);
if (!compileResolvedKeybindingRule(rule)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);
}

const configPath = resolveKeybindingsConfigPath();
const customConfig = loadCustomKeybindingsConfig(logger);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig;

if (nextConfig.length > MAX_KEYBINDINGS) {
logger.warn("truncating keybindings config to max entries", {
path: configPath,
maxEntries: MAX_KEYBINDINGS,
});
}
return [...DEFAULT_RESOLVED_KEYBINDINGS];

fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(cappedConfig, null, 2)}\n`, "utf8");

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig);
return mergeWithDefaultKeybindings(compiledCustomConfig);
}
113 changes: 113 additions & 0 deletions apps/server/src/projectRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import { ProjectRegistry } from "./projectRegistry";

const tempDirs: string[] = [];

function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}

afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe("ProjectRegistry scripts", () => {
it("stores and reloads project scripts", () => {
const stateDir = makeTempDir("t3code-project-registry-state-");
const projectDir = makeTempDir("t3code-project-registry-project-");
const registry = new ProjectRegistry(stateDir);

const created = registry.add({ cwd: projectDir }).project;
const updated = registry.updateScripts({
id: created.id,
scripts: [
{
id: "run",
name: "Run",
command: "bun run dev",
icon: "play",
runOnWorktreeCreate: false,
},
{
id: "setup",
name: "Setup",
command: "bun install",
icon: "configure",
runOnWorktreeCreate: true,
},
],
});

expect(updated.project.scripts).toHaveLength(2);
expect(updated.project.scripts[1]?.id).toBe("setup");
expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true);

const reloaded = new ProjectRegistry(stateDir);
const listed = reloaded.list();
expect(listed).toHaveLength(1);
expect(listed[0]?.scripts).toHaveLength(2);
expect(listed[0]?.scripts[0]?.id).toBe("run");
expect(listed[0]?.scripts[1]?.id).toBe("setup");
});

it("deduplicates script ids and allows only one setup script", () => {
const stateDir = makeTempDir("t3code-project-registry-state-");
const projectDir = makeTempDir("t3code-project-registry-project-");
const registry = new ProjectRegistry(stateDir);
const created = registry.add({ cwd: projectDir }).project;

const updated = registry.updateScripts({
id: created.id,
scripts: [
{
id: "setup",
name: "Setup",
command: "echo setup",
icon: "configure",
runOnWorktreeCreate: true,
},
{
id: "setup",
name: "Setup Duplicate",
command: "echo duplicate",
icon: "configure",
runOnWorktreeCreate: true,
},
{
id: "another",
name: "Another Setup",
command: "echo another",
icon: "play",
runOnWorktreeCreate: true,
},
],
});

expect(updated.project.scripts).toHaveLength(2);
expect(updated.project.scripts[0]?.id).toBe("setup");
expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true);
expect(updated.project.scripts[1]?.id).toBe("another");
expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false);
});

it("throws when updating scripts for an unknown project id", () => {
const stateDir = makeTempDir("t3code-project-registry-state-");
const registry = new ProjectRegistry(stateDir);

expect(() =>
registry.updateScripts({
id: "missing-project",
scripts: [],
}),
).toThrowError("Project not found");
});
});
Loading
Loading