v0.9.0 636c44c

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

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