Skip to content

Commit 0bccbcb

Browse files
committed
feat: refactor media query hook and improve app directory caching
- Removed the old `useMediaQuery` implementation from `file-list` and `ui` packages. - Introduced a new `useMediaQuery` hook in `ui-utils` for responsive media queries. - Updated `useAppDirs` in `ui-bridge` to use a caching mechanism with `useRef`. - Modified `BridgeProvider` in `useBridge` to avoid unnecessary state updates. - Upgraded TypeScript target to ES2022 in `ui-bridge` and `ui-focus` packages. - Added `vitest` for testing in `ui-focus` and `ui-utils` packages. - Created README files for `commands`, `extension-api`, `file-list`, `ui-bridge`, and `ui-focus` packages. - Implemented tests for `ActionQueue` in `file-list` and `FocusContextManager` in `ui-focus`. - Added platform detection utilities in `ui-utils`.
1 parent fc265b6 commit 0bccbcb

38 files changed

Lines changed: 970 additions & 442 deletions

packages/commands/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# @dotdirfm/commands
2+
3+
VS Code-compatible command registry and keybinding system for DotDir.
4+
5+
## Features
6+
7+
- Command registration, execution, and disposal with active-scope support
8+
- Multi-layer keybinding system (`default` < `extension` < `user`)
9+
- `when` clause evaluation with `&&`, `||`, `!` operators
10+
- Layout-independent keyboard event routing (physical code based)
11+
- Batch operations for context updates to avoid cascading notifications
12+
- React integration via context + `useCommandRegistry` hook
13+
14+
## Install
15+
16+
```bash
17+
pnpm add @dotdirfm/commands
18+
```
19+
20+
## Usage
21+
22+
```tsx
23+
import { CommandRegistryProvider, useCommandRegistry } from "@dotdirfm/commands";
24+
25+
function App() {
26+
return (
27+
<CommandRegistryProvider>
28+
<MyComponent />
29+
</CommandRegistryProvider>
30+
);
31+
}
32+
33+
function MyComponent() {
34+
const registry = useCommandRegistry();
35+
// Register commands, add keybindings, execute commands
36+
}
37+
```
38+
39+
## Exports
40+
41+
| Export | Description |
42+
|--------|-------------|
43+
| `CommandRegistry` | Main class for managing commands and keybindings |
44+
| `CommandRegistryProvider` / `useCommandRegistry` | React context and hook |
45+
| `runCommandSequence` | Execute a sequence of commands |
46+
| `formatKeybinding` | Render platform-specific key labels with symbols |
47+
| `commandIds.ts` | All command ID string constants |

packages/commands/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"scripts": {
2525
"build": "tsdown",
2626
"dev": "tsdown --watch",
27+
"test": "vitest run",
2728
"typecheck": "tsc --noEmit",
2829
"prepublishOnly": "pnpm build"
2930
},
@@ -45,11 +46,15 @@
4546
"publishConfig": {
4647
"access": "public"
4748
},
49+
"dependencies": {
50+
"@dotdirfm/ui-utils": "workspace:*"
51+
},
4852
"peerDependencies": {
4953
"react": "^19.0.0"
5054
},
5155
"devDependencies": {
52-
"tsdown": "^0.17.0"
56+
"tsdown": "^0.17.0",
57+
"vitest": "^4.1.5"
5358
},
5459
"packageManager": "pnpm@10.30.3"
5560
}

packages/commands/src/commands.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* 3. User (from keybindings.json)
1414
*/
1515

16+
import { isMac } from "@dotdirfm/ui-utils";
1617
import { createContext, createElement, useContext, useRef, type ReactNode } from "react";
1718

1819
export interface Command {
@@ -322,7 +323,7 @@ export class CommandRegistry {
322323

323324
for (const layer of layers) {
324325
for (const binding of this.keybindingLayers[layer]) {
325-
const normalizedKey = this.normalizeKey(this.isMac() ? (binding.mac ?? binding.key) : binding.key);
326+
const normalizedKey = this.normalizeKey(this.isMacPlatform() ? (binding.mac ?? binding.key) : binding.key);
326327
const resolvedBinding: ResolvedKeybinding = { ...binding, normalizedKey };
327328

328329
if (binding.command.startsWith("-")) {
@@ -354,8 +355,8 @@ export class CommandRegistry {
354355
return resolved;
355356
}
356357

357-
private isMac(): boolean {
358-
return navigator.platform.toUpperCase().includes("MAC");
358+
private isMacPlatform(): boolean {
359+
return isMac();
359360
}
360361

361362
/**
@@ -442,7 +443,7 @@ export class CommandRegistry {
442443
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return null;
443444

444445
const parts: string[] = [];
445-
if (e.metaKey) parts.push(this.isMac() ? "cmd" : "ctrl");
446+
if (e.metaKey) parts.push(this.isMacPlatform() ? "cmd" : "ctrl");
446447
if (e.ctrlKey) parts.push("ctrl");
447448
if (e.altKey) parts.push("alt");
448449
if (e.shiftKey) parts.push("shift");
@@ -455,7 +456,7 @@ export class CommandRegistry {
455456
}
456457

457458
private normalizeKey(key: string): string {
458-
const isMac = this.isMac();
459+
const isMac = this.isMacPlatform();
459460
return key
460461
.toLowerCase()
461462
.replace(/meta/g, isMac ? "cmd" : "ctrl")
@@ -502,14 +503,14 @@ export function useCommandRegistry(): CommandRegistry {
502503
}
503504

504505
export function formatKeybinding(binding: Keybinding): string {
505-
const isMac = navigator.platform.toUpperCase().includes("MAC");
506-
const key = isMac ? (binding.mac ?? binding.key) : binding.key;
506+
const isMacPlatform = isMac();
507+
const key = isMacPlatform ? (binding.mac ?? binding.key) : binding.key;
507508

508509
return key
509510
.split("+")
510511
.map((part) => {
511512
const p = part.trim().toLowerCase();
512-
if (isMac) {
513+
if (isMacPlatform) {
513514
if (p === "ctrl" || p === "cmd" || p === "mod") return "⌘";
514515
if (p === "alt") return "⌥";
515516
if (p === "shift") return "⇧";
@@ -530,5 +531,5 @@ export function formatKeybinding(binding: Keybinding): string {
530531
if (p === "tab") return "Tab";
531532
return p.toUpperCase();
532533
})
533-
.join(isMac ? "" : "+");
534+
.join(isMacPlatform ? "" : "+");
534535
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { describe, expect, it } from "vitest";
2+
import { CommandRegistry, formatKeybinding } from "../src";
3+
4+
function createRegistry(): CommandRegistry {
5+
return new CommandRegistry();
6+
}
7+
8+
describe("CommandRegistry", () => {
9+
describe("command registration", () => {
10+
it("registers and retrieves a command via contributions", () => {
11+
const registry = createRegistry();
12+
registry.registerContributions([
13+
{
14+
command: "test.command",
15+
title: "Test Command",
16+
category: "Test",
17+
},
18+
]);
19+
20+
const cmd = registry.getCommand("test.command");
21+
expect(cmd).toBeDefined();
22+
expect(cmd!.id).toBe("test.command");
23+
expect(cmd!.title).toBe("Test Command");
24+
expect(cmd!.category).toBe("Test");
25+
});
26+
27+
it("registers multiple commands and returns all", () => {
28+
const registry = createRegistry();
29+
registry.registerContributions([
30+
{ command: "cmd.a", title: "A" },
31+
{ command: "cmd.b", title: "B" },
32+
]);
33+
34+
const all = registry.getAllCommands();
35+
expect(all).toHaveLength(2);
36+
});
37+
});
38+
39+
describe("executeCommand", () => {
40+
it("executes a registered command handler", async () => {
41+
const registry = createRegistry();
42+
let executed = false;
43+
44+
registry.registerCommand("test.run", () => {
45+
executed = true;
46+
});
47+
48+
await registry.executeCommand("test.run");
49+
expect(executed).toBe(true);
50+
});
51+
52+
it("passes arguments to the handler", async () => {
53+
const registry = createRegistry();
54+
let receivedArgs: unknown[] = [];
55+
56+
registry.registerCommand("test.args", (...args) => {
57+
receivedArgs = args;
58+
});
59+
60+
await registry.executeCommand("test.args", "a", 42, { key: "val" });
61+
expect(receivedArgs).toEqual(["a", 42, { key: "val" }]);
62+
});
63+
64+
it("executes async handlers", async () => {
65+
const registry = createRegistry();
66+
let executed = false;
67+
68+
registry.registerCommand("test.async", async () => {
69+
await new Promise((r) => setTimeout(r, 1));
70+
executed = true;
71+
});
72+
73+
await registry.executeCommand("test.async");
74+
expect(executed).toBe(true);
75+
});
76+
77+
it("does not throw when command is not found", async () => {
78+
const registry = createRegistry();
79+
await expect(
80+
registry.executeCommand("nonexistent.command"),
81+
).resolves.toBeUndefined();
82+
});
83+
84+
it("prefers active-scoped handlers over inactive ones", async () => {
85+
const registry = createRegistry();
86+
const results: string[] = [];
87+
88+
registry.registerCommand("test.scope", () => results.push("default"));
89+
90+
registry.registerCommand(
91+
"test.scope",
92+
() => results.push("active"),
93+
{ isActive: () => true },
94+
);
95+
96+
registry.registerCommand(
97+
"test.scope",
98+
() => results.push("inactive"),
99+
{ isActive: () => false },
100+
);
101+
102+
await registry.executeCommand("test.scope");
103+
expect(results).toEqual(["active"]);
104+
});
105+
106+
it("falls back to the last registered handler when no active scope matches", async () => {
107+
const registry = createRegistry();
108+
const results: string[] = [];
109+
110+
registry.registerCommand(
111+
"test.fallback",
112+
() => results.push("inactive"),
113+
{ isActive: () => false },
114+
);
115+
116+
registry.registerCommand("test.fallback", () =>
117+
results.push("fallback"),
118+
);
119+
120+
await registry.executeCommand("test.fallback");
121+
expect(results).toEqual(["fallback"]);
122+
});
123+
});
124+
125+
describe("keybindings", () => {
126+
it("returns empty keybindings by default", () => {
127+
const registry = createRegistry();
128+
expect(registry.getKeybindings()).toEqual([]);
129+
});
130+
131+
it("adds and retrieves keybindings for a layer", () => {
132+
const registry = createRegistry();
133+
registry.registerKeybinding(
134+
{ command: "test.cmd", key: "ctrl+a" },
135+
"default",
136+
);
137+
const bindings = registry.getKeybindings();
138+
expect(bindings).toHaveLength(1);
139+
expect(bindings[0]!.key).toBe("ctrl+a");
140+
});
141+
142+
it("filters out keybindings with empty command", () => {
143+
const registry = createRegistry();
144+
registry.registerKeybinding(
145+
{ command: "", key: "ctrl+a" },
146+
"default",
147+
);
148+
expect(registry.getKeybindings()).toHaveLength(0);
149+
});
150+
151+
it("resolves keybindings for a specific layer", () => {
152+
const registry = createRegistry();
153+
registry.registerKeybinding(
154+
{ command: "cmd.a", key: "ctrl+x" },
155+
"default",
156+
);
157+
registry.registerKeybinding(
158+
{ command: "cmd.b", key: "ctrl+y" },
159+
"user",
160+
);
161+
162+
const defaultBindings = registry.getKeybindingsForLayer("default");
163+
expect(defaultBindings).toHaveLength(1);
164+
expect(defaultBindings[0]!.command).toBe("cmd.a");
165+
});
166+
});
167+
168+
describe("context", () => {
169+
it("updates and retrieves context values", () => {
170+
const registry = createRegistry();
171+
registry.setContext("panelActive", true);
172+
expect(registry.getContext("panelActive")).toBe(true);
173+
174+
registry.setContext("readonly", false);
175+
expect(registry.getContext("readonly")).toBe(false);
176+
});
177+
});
178+
});
179+
180+
describe("formatKeybinding", () => {
181+
it("formats a simple key", () => {
182+
const result = formatKeybinding({ command: "test", key: "a" });
183+
expect(result.toUpperCase()).toBe("A");
184+
});
185+
186+
it("formats a keybinding with modifiers", () => {
187+
const result = formatKeybinding({ command: "test", key: "ctrl+shift+p" });
188+
expect(result).toContain("P");
189+
});
190+
191+
it("formats special keys", () => {
192+
expect(formatKeybinding({ command: "test", key: "enter" })).toContain("↵");
193+
expect(formatKeybinding({ command: "test", key: "escape" })).toContain("Esc");
194+
expect(formatKeybinding({ command: "test", key: "tab" })).toContain("Tab");
195+
expect(formatKeybinding({ command: "test", key: "up" })).toContain("↑");
196+
expect(formatKeybinding({ command: "test", key: "down" })).toContain("↓");
197+
expect(formatKeybinding({ command: "test", key: "left" })).toContain("←");
198+
expect(formatKeybinding({ command: "test", key: "right" })).toContain("→");
199+
expect(formatKeybinding({ command: "test", key: "space" })).toContain("Space");
200+
expect(formatKeybinding({ command: "test", key: "backspace" })).toContain("⌫");
201+
expect(formatKeybinding({ command: "test", key: "delete" })).toContain("Del");
202+
});
203+
204+
it("formats f-keys", () => {
205+
const result = formatKeybinding({ command: "test", key: "f5" });
206+
expect(result).toBe("F5");
207+
});
208+
209+
it("does not throw when called in node environment", () => {
210+
// formatKeybinding uses isMac() which falls back gracefully
211+
expect(() => formatKeybinding({ command: "test", key: "a" })).not.toThrow();
212+
});
213+
});

packages/commands/vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
environment: "node",
6+
include: ["tests/**/*.test.ts"],
7+
},
8+
});

packages/extension-api/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# @dotdirfm/extension-api
2+
3+
Shared type definitions for the DotDir extension system. Pure types — no runtime code.
4+
5+
## Purpose
6+
7+
Defines the contract between host and extensions:
8+
9+
- **Viewer extensions** — mount/unmount/focus
10+
- **Editor extensions** — mount/unmount/focus/setDirty/setLanguage
11+
- **FS provider extensions** — listEntries/readFileRange
12+
- **Host API** — readFile, statFile, file watching, theme, commands
13+
- **Global window augmentation** (`window.dotdir`)
14+
15+
## Install
16+
17+
```bash
18+
pnpm add @dotdirfm/extension-api
19+
```
20+
21+
## Exports
22+
23+
| Export | Description |
24+
|--------|-------------|
25+
| `HostApi` | API provided by the host to extensions |
26+
| `ViewerExtensionApi` | Interface for viewer extensions |
27+
| `EditorExtensionApi` | Interface for editor extensions |
28+
| `FsProviderExtensionApi` | Interface for filesystem provider extensions |
29+
| `ColorThemeData`, `SystemThemeKind`, `ThemePreference` | Theme-related types |
30+
| `FsProviderEntry`, `EntryKind` | Filesystem entry types |

0 commit comments

Comments
 (0)