1/**
2 * Workspace Controller Module
3 *
4 * Provides the initial scaffolding for Zellij-inspired pane management.
5 * Currently tracks pane mode state, exposes configuration to the page,
6 * and wires keyboard service shortcuts based on configuration defaults.
7 */
8
9import { keyboardService } from "$lib/keyboard";
10
11const ACTIVATION_COMPONENT_ID = "workspace-pane-activation";
12const ACTION_COMPONENT_ID = "workspace-pane-actions";
13
14export interface WorkspaceConfigPayload {
15 enable_zellij_controls?: boolean;
16 pane_mode?: {
17 activation_shortcut?: string;
18 timeout_ms?: number;
19 action_bindings?: Record<string, string>;
20 };
21 tabs?: {
22 new_tab?: string;
23 close_tab?: string;
24 next_tab?: string;
25 previous_tab?: string;
26 };
27 popups?: {
28 placement?: string;
29 open_in_new_pane?: boolean;
30 follow_pane_context?: boolean;
31 };
32}
33
34export interface WorkspacePaneModeConfig {
35 activationShortcut: string;
36 timeoutMs: number;
37 actionBindings: Record<string, string>;
38}
39
40export interface WorkspaceTabsConfig {
41 newTab: string;
42 closeTab: string;
43 nextTab: string;
44 previousTab: string;
45}
46
47export interface WorkspacePopupConfig {
48 placement: string;
49 openInNewPane: boolean;
50 followPaneContext: boolean;
51}
52
53export interface WorkspaceConfigNormalized {
54 enableZellijControls: boolean;
55 paneMode: WorkspacePaneModeConfig;
56 tabs: WorkspaceTabsConfig;
57 popups: WorkspacePopupConfig;
58}
59
60export interface WorkspaceRuntime {
61 enterPaneMode: () => void;
62 exitPaneMode: (reason?: string) => void;
63 isPaneModeActive: () => boolean;
64 getConfig: () => WorkspaceConfigNormalized;
65 getInstanceId: () => string;
66 isActiveInstance: () => boolean;
67}
68
69const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
70 enableZellijControls: true,
71 paneMode: {
72 activationShortcut: "cmdorctrl+p",
73 timeoutMs: 3000,
74 actionBindings: {
75 arrowright: "split-right",
76 arrowleft: "split-left",
77 arrowup: "split-up",
78 arrowdown: "split-down",
79 r: "split-right",
80 l: "split-left",
81 u: "split-up",
82 d: "split-down",
83 s: "stack-pane",
84 x: "close-pane",
85 enter: "confirm",
86 escape: "cancel",
87 },
88 },
89 tabs: {
90 newTab: "cmdorctrl+t",
91 closeTab: "cmdorctrl+w",
92 nextTab: "cmdorctrl+tab",
93 previousTab: "cmdorctrl+shift+tab",
94 },
95 popups: {
96 placement: "right",
97 openInNewPane: true,
98 followPaneContext: true,
99 },
100};
101
102function normalizeWorkspaceConfig(
103 payload?: WorkspaceConfigPayload | null,
104): WorkspaceConfigNormalized {
105 const paneModePayload = payload?.pane_mode ?? {};
106 const tabsPayload = payload?.tabs ?? {};
107 const popupsPayload = payload?.popups ?? {};
108
109 return {
110 enableZellijControls:
111 payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
112 paneMode: {
113 activationShortcut:
114 paneModePayload.activation_shortcut ??
115 DEFAULT_CONFIG.paneMode.activationShortcut,
116 timeoutMs:
117 paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
118 actionBindings: {
119 ...DEFAULT_CONFIG.paneMode.actionBindings,
120 ...(paneModePayload.action_bindings ?? {}),
121 },
122 },
123 tabs: {
124 newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
125 closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
126 nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
127 previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab,
128 },
129 popups: {
130 placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
131 openInNewPane:
132 popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
133 followPaneContext:
134 popupsPayload.follow_pane_context ??
135 DEFAULT_CONFIG.popups.followPaneContext,
136 },
137 };
138}
139
140class WorkspaceController implements WorkspaceRuntime {
141 private config: WorkspaceConfigNormalized = DEFAULT_CONFIG;
142 private paneModeActive = false;
143 private paneModeTimer: ReturnType<typeof setTimeout> | null = null;
144 private instanceId: string;
145 private lastActiveCheckTime = 0;
146
147 constructor() {
148 // Generate unique instance ID for this webview
149 this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
150 console.log(
151 `[workspace] Created WorkspaceController instance: ${this.instanceId}`,
152 );
153 }
154
155 getInstanceId(): string {
156 return this.instanceId;
157 }
158
159 isActiveInstance(): boolean {
160 // Cache active check for 50ms to avoid excessive checks
161 const now = Date.now();
162 if (now - this.lastActiveCheckTime < 50) {
163 return true; // Assume active within debounce window
164 }
165 this.lastActiveCheckTime = now;
166
167 // Check if this webview has focus by attempting to query document.hasFocus()
168 // In a multi-pane environment, only the focused webview should handle pane mode
169 try {
170 const hasFocus = document.hasFocus();
171 const isVisible = document.visibilityState === "visible";
172 const isActive = hasFocus && isVisible;
173
174 if (!isActive) {
175 console.log(
176 `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`,
177 );
178 }
179
180 return isActive;
181 } catch (error) {
182 console.warn(
183 "[workspace] Failed to check active state, assuming active:",
184 error,
185 );
186 return true; // Fallback to active if check fails
187 }
188 }
189
190 configure(payload?: WorkspaceConfigPayload | null): void {
191 const normalized = normalizeWorkspaceConfig(payload);
192 this.unregisterShortcuts();
193 this.config = normalized;
194
195 if (!normalized.enableZellijControls) {
196 console.log("[workspace] Zellij controls disabled via config");
197 window.__dumber_workspace = this.exportRuntime();
198 return;
199 }
200
201 this.registerShortcuts();
202 window.__dumber_workspace = this.exportRuntime();
203 console.log(
204 "[workspace] Zellij controls enabled with config:",
205 this.config,
206 );
207 }
208
209 private bridge(message: Record<string, unknown>): void {
210 const bridge = window.webkit?.messageHandlers?.dumber;
211 if (!bridge || typeof bridge.postMessage !== "function") {
212 return;
213 }
214
215 try {
216 bridge.postMessage(
217 JSON.stringify({
218 type: "workspace",
219 ...message,
220 }),
221 );
222 } catch (error) {
223 console.error("[workspace] Failed to post workspace message", error);
224 }
225 }
226
227 enterPaneMode(): void {
228 if (!this.config.enableZellijControls) return;
229
230 // Only allow the active instance to enter pane mode
231 if (!this.isActiveInstance()) {
232 console.log(
233 `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`,
234 );
235 return;
236 }
237
238 console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
239 this.paneModeActive = true;
240 this.restartPaneModeTimer();
241 this.emitWorkspaceEvent("pane-mode-entered", {
242 action: "enter",
243 config: this.config,
244 instanceId: this.instanceId,
245 });
246 this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId });
247 this.showToast("Pane mode: awaiting direction");
248 }
249
250 exitPaneMode(reason: string = "exit"): void {
251 if (!this.paneModeActive) return;
252
253 this.paneModeActive = false;
254 if (this.paneModeTimer) {
255 clearTimeout(this.paneModeTimer);
256 this.paneModeTimer = null;
257 }
258 this.bridge({ event: "pane-mode-exited", reason });
259 }
260
261 isPaneModeActive(): boolean {
262 return this.paneModeActive;
263 }
264
265 getConfig(): WorkspaceConfigNormalized {
266 return this.config;
267 }
268
269 private registerShortcuts(): void {
270 keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [
271 {
272 key: this.config.paneMode.activationShortcut,
273 handler: () => this.enterPaneMode(),
274 description: "Enter pane mode",
275 preventDefault: true,
276 stopPropagation: true,
277 whenFocused: false,
278 },
279 ]);
280
281 const paneShortcuts = Object.entries(
282 this.config.paneMode.actionBindings,
283 ).map(([key, action]) => ({
284 key,
285 handler: () => this.handlePaneAction(action, key),
286 description: `Pane action: ${action}`,
287 preventDefault: true,
288 stopPropagation: true,
289 whenFocused: false,
290 }));
291
292 keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
293 }
294
295 private unregisterShortcuts(): void {
296 keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID);
297 keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID);
298 }
299
300 private handlePaneAction(action: string, key?: string): void {
301 if (!this.paneModeActive) {
302 return;
303 }
304
305 switch (action) {
306 case "split-right":
307 this.emitWorkspaceEvent("pane-split", {
308 direction: "right",
309 config: this.config,
310 });
311 this.bridge({ event: "pane-split", direction: "right", action: "split-right" });
312 this.showToast("Split pane to the right");
313 this.exitPaneMode("split-right");
314 break;
315 case "split-left":
316 this.emitWorkspaceEvent("pane-split", {
317 direction: "left",
318 config: this.config,
319 });
320 this.bridge({ event: "pane-split", direction: "left", action: "split-left" });
321 this.showToast("Split pane to the left");
322 this.exitPaneMode("split-left");
323 break;
324 case "split-up":
325 this.emitWorkspaceEvent("pane-split", {
326 direction: "up",
327 config: this.config,
328 });
329 this.bridge({ event: "pane-split", direction: "up", action: "split-up" });
330 this.showToast("Split pane upwards");
331 this.exitPaneMode("split-up");
332 break;
333 case "split-down":
334 this.emitWorkspaceEvent("pane-split", {
335 direction: "down",
336 config: this.config,
337 });
338 this.bridge({ event: "pane-split", direction: "down", action: "split-down" });
339 this.showToast("Split pane downwards");
340 this.exitPaneMode("split-down");
341 break;
342 case "stack-pane":
343 this.bridge({ event: "pane-stack", action: "stack-pane" });
344 this.showToast("Stacked pane");
345 this.exitPaneMode("stack-pane");
346 break;
347 case "close-pane":
348 this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
349 this.bridge({ event: "pane-close", action });
350 this.showToast("Closed pane");
351 this.exitPaneMode("close-pane");
352 break;
353 case "confirm":
354 this.bridge({ event: "pane-confirmed" });
355 this.showToast("Pane mode confirmed");
356 this.exitPaneMode("confirm");
357 break;
358 case "cancel":
359 this.bridge({ event: "pane-cancelled" });
360 this.showToast("Pane mode cancelled");
361 this.exitPaneMode("cancel");
362 break;
363 default:
364 console.warn("[workspace] Unhandled pane action:", action);
365 }
366 }
367
368 private restartPaneModeTimer(): void {
369 if (this.paneModeTimer) {
370 clearTimeout(this.paneModeTimer);
371 }
372
373 if (this.config.paneMode.timeoutMs > 0) {
374 this.paneModeTimer = setTimeout(() => {
375 this.showToast("Pane mode timed out");
376 this.exitPaneMode("timeout");
377 }, this.config.paneMode.timeoutMs);
378 }
379 }
380
381 private showToast(message: string): void {
382 if (typeof window.__dumber_showToast === "function") {
383 window.__dumber_showToast(message, 2000, "info");
384 } else {
385 console.log("[workspace toast]", message);
386 }
387 }
388
389 private emitWorkspaceEvent(
390 event: string,
391 detail: Record<string, unknown>,
392 ): void {
393 document.dispatchEvent(
394 new CustomEvent(`dumber:workspace-${event}`, { detail }),
395 );
396 }
397
398 private exportRuntime(): WorkspaceRuntime {
399 return {
400 enterPaneMode: () => this.enterPaneMode(),
401 exitPaneMode: (reason?: string) =>
402 this.exitPaneMode(reason ?? "external"),
403 isPaneModeActive: () => this.isPaneModeActive(),
404 getConfig: () => this.getConfig(),
405 getInstanceId: () => this.getInstanceId(),
406 isActiveInstance: () => this.isActiveInstance(),
407 };
408 }
409}
410
411const workspaceController = new WorkspaceController();
412let listenersAttached = false;
413
414export function initializeWorkspace(
415 payload?: WorkspaceConfigPayload | null,
416): void {
417 if (!listenersAttached) {
418 document.addEventListener("dumber:workspace-config", (event: Event) => {
419 const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
420 workspaceController.configure(detail);
421 });
422 listenersAttached = true;
423 }
424
425 const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null;
426 workspaceController.configure(bootstrapPayload);
427}
428
429export { workspaceController };