v0.11.0 dc50343

workspace.ts RawHistoryFolder

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 };

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close