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 x: "close-pane",
84 enter: "confirm",
85 escape: "cancel",
86 },
87 },
88 tabs: {
89 newTab: "cmdorctrl+t",
90 closeTab: "cmdorctrl+w",
91 nextTab: "cmdorctrl+tab",
92 previousTab: "cmdorctrl+shift+tab",
93 },
94 popups: {
95 placement: "right",
96 openInNewPane: true,
97 followPaneContext: true,
98 },
99};
100
101function normalizeWorkspaceConfig(
102 payload?: WorkspaceConfigPayload | null,
103): WorkspaceConfigNormalized {
104 const paneModePayload = payload?.pane_mode ?? {};
105 const tabsPayload = payload?.tabs ?? {};
106 const popupsPayload = payload?.popups ?? {};
107
108 return {
109 enableZellijControls:
110 payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
111 paneMode: {
112 activationShortcut:
113 paneModePayload.activation_shortcut ??
114 DEFAULT_CONFIG.paneMode.activationShortcut,
115 timeoutMs:
116 paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
117 actionBindings: {
118 ...DEFAULT_CONFIG.paneMode.actionBindings,
119 ...(paneModePayload.action_bindings ?? {}),
120 },
121 },
122 tabs: {
123 newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
124 closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
125 nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
126 previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab,
127 },
128 popups: {
129 placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
130 openInNewPane:
131 popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
132 followPaneContext:
133 popupsPayload.follow_pane_context ??
134 DEFAULT_CONFIG.popups.followPaneContext,
135 },
136 };
137}
138
139class WorkspaceController implements WorkspaceRuntime {
140 private config: WorkspaceConfigNormalized = DEFAULT_CONFIG;
141 private paneModeActive = false;
142 private paneModeTimer: ReturnType<typeof setTimeout> | null = null;
143 private instanceId: string;
144 private lastActiveCheckTime = 0;
145
146 constructor() {
147 // Generate unique instance ID for this webview
148 this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
149 console.log(
150 `[workspace] Created WorkspaceController instance: ${this.instanceId}`,
151 );
152 }
153
154 getInstanceId(): string {
155 return this.instanceId;
156 }
157
158 isActiveInstance(): boolean {
159 // Cache active check for 50ms to avoid excessive checks
160 const now = Date.now();
161 if (now - this.lastActiveCheckTime < 50) {
162 return true; // Assume active within debounce window
163 }
164 this.lastActiveCheckTime = now;
165
166 // Check if this webview has focus by attempting to query document.hasFocus()
167 // In a multi-pane environment, only the focused webview should handle pane mode
168 try {
169 const hasFocus = document.hasFocus();
170 const isVisible = document.visibilityState === "visible";
171 const isActive = hasFocus && isVisible;
172
173 if (!isActive) {
174 console.log(
175 `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`,
176 );
177 }
178
179 return isActive;
180 } catch (error) {
181 console.warn(
182 "[workspace] Failed to check active state, assuming active:",
183 error,
184 );
185 return true; // Fallback to active if check fails
186 }
187 }
188
189 configure(payload?: WorkspaceConfigPayload | null): void {
190 const normalized = normalizeWorkspaceConfig(payload);
191 this.unregisterShortcuts();
192 this.config = normalized;
193
194 if (!normalized.enableZellijControls) {
195 console.log("[workspace] Zellij controls disabled via config");
196 window.__dumber_workspace = this.exportRuntime();
197 return;
198 }
199
200 this.registerShortcuts();
201 window.__dumber_workspace = this.exportRuntime();
202 console.log(
203 "[workspace] Zellij controls enabled with config:",
204 this.config,
205 );
206 }
207
208 private bridge(message: Record<string, unknown>): void {
209 const bridge = window.webkit?.messageHandlers?.dumber;
210 if (!bridge || typeof bridge.postMessage !== "function") {
211 return;
212 }
213
214 try {
215 bridge.postMessage(
216 JSON.stringify({
217 type: "workspace",
218 ...message,
219 }),
220 );
221 } catch (error) {
222 console.error("[workspace] Failed to post workspace message", error);
223 }
224 }
225
226 enterPaneMode(): void {
227 if (!this.config.enableZellijControls) return;
228
229 // Only allow the active instance to enter pane mode
230 if (!this.isActiveInstance()) {
231 console.log(
232 `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`,
233 );
234 return;
235 }
236
237 console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
238 this.paneModeActive = true;
239 this.restartPaneModeTimer();
240 this.emitWorkspaceEvent("pane-mode-entered", {
241 action: "enter",
242 config: this.config,
243 instanceId: this.instanceId,
244 });
245 this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId });
246 this.showToast("Pane mode: awaiting direction");
247 }
248
249 exitPaneMode(reason: string = "exit"): void {
250 if (!this.paneModeActive) return;
251
252 this.paneModeActive = false;
253 if (this.paneModeTimer) {
254 clearTimeout(this.paneModeTimer);
255 this.paneModeTimer = null;
256 }
257 this.emitWorkspaceEvent("pane-mode-exited", { action: reason });
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" });
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" });
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" });
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" });
339 this.showToast("Split pane downwards");
340 this.exitPaneMode("split-down");
341 break;
342 case "close-pane":
343 this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
344 this.bridge({ event: "pane-close", action });
345 this.showToast("Closed pane");
346 this.exitPaneMode("close-pane");
347 break;
348 case "confirm":
349 this.bridge({ event: "pane-confirmed" });
350 this.showToast("Pane mode confirmed");
351 this.exitPaneMode("confirm");
352 break;
353 case "cancel":
354 this.bridge({ event: "pane-cancelled" });
355 this.showToast("Pane mode cancelled");
356 this.exitPaneMode("cancel");
357 break;
358 default:
359 console.warn("[workspace] Unhandled pane action:", action);
360 }
361 }
362
363 private restartPaneModeTimer(): void {
364 if (this.paneModeTimer) {
365 clearTimeout(this.paneModeTimer);
366 }
367
368 if (this.config.paneMode.timeoutMs > 0) {
369 this.paneModeTimer = setTimeout(() => {
370 this.showToast("Pane mode timed out");
371 this.exitPaneMode("timeout");
372 }, this.config.paneMode.timeoutMs);
373 }
374 }
375
376 private showToast(message: string): void {
377 if (typeof window.__dumber_showToast === "function") {
378 window.__dumber_showToast(message, 2000, "info");
379 } else {
380 console.log("[workspace toast]", message);
381 }
382 }
383
384 private emitWorkspaceEvent(
385 event: string,
386 detail: Record<string, unknown>,
387 ): void {
388 document.dispatchEvent(
389 new CustomEvent(`dumber:workspace-${event}`, { detail }),
390 );
391 }
392
393 private exportRuntime(): WorkspaceRuntime {
394 return {
395 enterPaneMode: () => this.enterPaneMode(),
396 exitPaneMode: (reason?: string) =>
397 this.exitPaneMode(reason ?? "external"),
398 isPaneModeActive: () => this.isPaneModeActive(),
399 getConfig: () => this.getConfig(),
400 getInstanceId: () => this.getInstanceId(),
401 isActiveInstance: () => this.isActiveInstance(),
402 };
403 }
404}
405
406const workspaceController = new WorkspaceController();
407let listenersAttached = false;
408
409export function initializeWorkspace(
410 payload?: WorkspaceConfigPayload | null,
411): void {
412 if (!listenersAttached) {
413 document.addEventListener("dumber:workspace-config", (event: Event) => {
414 const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
415 workspaceController.configure(detail);
416 });
417 listenersAttached = true;
418 }
419
420 const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null;
421 workspaceController.configure(bootstrapPayload);
422}
423
424export { workspaceController };