detached ba9ac75

feat(config): add zellij-style workspace configuration system

commit
ba9ac75d19cef8800b048e47ca97fa2d729ee7cc
author
bamen · · 2025-09-19 03:41 UTC
parent
a7f39d5
signature
unsigned
3 files · +104 -0
M internal/config/config.go +52 -0
@@ -40,6 +40,8 @@ type Config struct {
4040 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
4141 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
4242 UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
43+ // Workspace defines workspace, pane, and tab handling behaviour.
44+ Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
4345 }
4446
4547 // RenderingMode selects GPU vs CPU rendering.
@@ -209,6 +211,9 @@ type DebugConfig struct {
209211
210212 // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
211213 EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
214+
215+ // Enable workspace navigation and focus debug logs
216+ EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
212217 }
213218
214219 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -219,6 +224,40 @@ type APISecurityConfig struct {
219224 RequireToken bool `mapstructure:"require_token" yaml:"require_token"`
220225 }
221226
227+// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
228+type WorkspaceConfig struct {
229+ // EnableZellijControls toggles Zellij-inspired keybindings.
230+ EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
231+ // PaneMode defines modal pane behaviour and bindings.
232+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
233+ // Tabs holds classic browser tab shortcuts.
234+ Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"`
235+ // Popups configures default popup placement rules.
236+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
237+}
238+
239+// PaneModeConfig defines modal behaviour for pane management.
240+type PaneModeConfig struct {
241+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
242+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
243+ ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`
244+}
245+
246+// TabKeyConfig defines Zellij-inspired tab shortcuts.
247+type TabKeyConfig struct {
248+ NewTab string `mapstructure:"new_tab" yaml:"new_tab" json:"new_tab"`
249+ CloseTab string `mapstructure:"close_tab" yaml:"close_tab" json:"close_tab"`
250+ NextTab string `mapstructure:"next_tab" yaml:"next_tab" json:"next_tab"`
251+ PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
252+}
253+
254+// PopupBehaviorConfig defines handling for popup windows.
255+type PopupBehaviorConfig struct {
256+ Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
257+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
258+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
259+}
260+
222261 // Manager handles configuration loading, watching, and reloading.
223262 type Manager struct {
224263 config *Config
@@ -612,6 +651,19 @@ func (m *Manager) setDefaults() {
612651 // Rendering defaults
613652 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
614653 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
654+
655+ // Workspace defaults
656+ m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
657+ m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
658+ m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
659+ m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings)
660+ m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
661+ m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
662+ m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
663+ m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
664+ m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
665+ m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
666+ m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
615667 }
616668
617669 func (m *Manager) ensurePersistedDefaults(cfg *Config) {
@@ -40,6 +40,8 @@ type Config struct {
40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
41 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.41 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
42 UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`42 UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
43+ // Workspace defines workspace, pane, and tab handling behaviour.
44+ Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
43 }45 }
44 46
45 // RenderingMode selects GPU vs CPU rendering.47 // RenderingMode selects GPU vs CPU rendering.
@@ -209,6 +211,9 @@ type DebugConfig struct {
209 211
210 // Enable general debug mode (equivalent to DUMBER_DEBUG env var)212 // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
211 EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`213 EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
214+
215+ // Enable workspace navigation and focus debug logs
216+ EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
212 }217 }
213 218
214 // APISecurityConfig holds optional API key protection for dumb://api endpoints219 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -219,6 +224,40 @@ type APISecurityConfig struct {
219 RequireToken bool `mapstructure:"require_token" yaml:"require_token"`224 RequireToken bool `mapstructure:"require_token" yaml:"require_token"`
220 }225 }
221 226
227+// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
228+type WorkspaceConfig struct {
229+ // EnableZellijControls toggles Zellij-inspired keybindings.
230+ EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
231+ // PaneMode defines modal pane behaviour and bindings.
232+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
233+ // Tabs holds classic browser tab shortcuts.
234+ Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"`
235+ // Popups configures default popup placement rules.
236+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
237+}
238+
239+// PaneModeConfig defines modal behaviour for pane management.
240+type PaneModeConfig struct {
241+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
242+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
243+ ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`
244+}
245+
246+// TabKeyConfig defines Zellij-inspired tab shortcuts.
247+type TabKeyConfig struct {
248+ NewTab string `mapstructure:"new_tab" yaml:"new_tab" json:"new_tab"`
249+ CloseTab string `mapstructure:"close_tab" yaml:"close_tab" json:"close_tab"`
250+ NextTab string `mapstructure:"next_tab" yaml:"next_tab" json:"next_tab"`
251+ PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
252+}
253+
254+// PopupBehaviorConfig defines handling for popup windows.
255+type PopupBehaviorConfig struct {
256+ Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
257+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
258+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
259+}
260+
222 // Manager handles configuration loading, watching, and reloading.261 // Manager handles configuration loading, watching, and reloading.
223 type Manager struct {262 type Manager struct {
224 config *Config263 config *Config
@@ -612,6 +651,19 @@ func (m *Manager) setDefaults() {
612 // Rendering defaults651 // Rendering defaults
613 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))652 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
614 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)653 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
654+
655+ // Workspace defaults
656+ m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
657+ m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
658+ m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
659+ m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings)
660+ m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
661+ m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
662+ m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
663+ m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
664+ m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
665+ m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
666+ m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
615 }667 }
616 668
617 func (m *Manager) ensurePersistedDefaults(cfg *Config) {669 func (m *Manager) ensurePersistedDefaults(cfg *Config) {
M internal/config/defaults.go +36 -0
@@ -25,6 +25,11 @@ const (
2525
2626 // Appearance defaults
2727 defaultFontSize = 16 // points
28+
29+ // Workspace defaults
30+ defaultPaneActivationShortcut = "cmdorctrl+p"
31+ defaultPaneTimeoutMilliseconds = 3000
32+ defaultPopupPlacement = "right"
2833 )
2934
3035 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -146,6 +151,37 @@ func DefaultConfig() *Config {
146151 },
147152 RenderingMode: RenderingModeGPU,
148153 UseDomZoom: false,
154+ Workspace: WorkspaceConfig{
155+ EnableZellijControls: true,
156+ PaneMode: PaneModeConfig{
157+ ActivationShortcut: defaultPaneActivationShortcut,
158+ TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
159+ ActionBindings: map[string]string{
160+ "arrowright": "split-right",
161+ "arrowleft": "split-left",
162+ "arrowup": "split-up",
163+ "arrowdown": "split-down",
164+ "r": "split-right",
165+ "l": "split-left",
166+ "u": "split-up",
167+ "d": "split-down",
168+ "x": "close-pane",
169+ "enter": "confirm",
170+ "escape": "cancel",
171+ },
172+ },
173+ Tabs: TabKeyConfig{
174+ NewTab: "cmdorctrl+t",
175+ CloseTab: "cmdorctrl+w",
176+ NextTab: "cmdorctrl+tab",
177+ PreviousTab: "cmdorctrl+shift+tab",
178+ },
179+ Popups: PopupBehaviorConfig{
180+ Placement: defaultPopupPlacement,
181+ OpenInNewPane: true,
182+ FollowPaneContext: true,
183+ },
184+ },
149185 }
150186 }
151187
@@ -25,6 +25,11 @@ const (
25 25
26 // Appearance defaults26 // Appearance defaults
27 defaultFontSize = 16 // points27 defaultFontSize = 16 // points
28+
29+ // Workspace defaults
30+ defaultPaneActivationShortcut = "cmdorctrl+p"
31+ defaultPaneTimeoutMilliseconds = 3000
32+ defaultPopupPlacement = "right"
28 )33 )
29 34
30 // getDefaultLogDir returns the default log directory, falls back to empty string on error35 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -146,6 +151,37 @@ func DefaultConfig() *Config {
146 },151 },
147 RenderingMode: RenderingModeGPU,152 RenderingMode: RenderingModeGPU,
148 UseDomZoom: false,153 UseDomZoom: false,
154+ Workspace: WorkspaceConfig{
155+ EnableZellijControls: true,
156+ PaneMode: PaneModeConfig{
157+ ActivationShortcut: defaultPaneActivationShortcut,
158+ TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
159+ ActionBindings: map[string]string{
160+ "arrowright": "split-right",
161+ "arrowleft": "split-left",
162+ "arrowup": "split-up",
163+ "arrowdown": "split-down",
164+ "r": "split-right",
165+ "l": "split-left",
166+ "u": "split-up",
167+ "d": "split-down",
168+ "x": "close-pane",
169+ "enter": "confirm",
170+ "escape": "cancel",
171+ },
172+ },
173+ Tabs: TabKeyConfig{
174+ NewTab: "cmdorctrl+t",
175+ CloseTab: "cmdorctrl+w",
176+ NextTab: "cmdorctrl+tab",
177+ PreviousTab: "cmdorctrl+shift+tab",
178+ },
179+ Popups: PopupBehaviorConfig{
180+ Placement: defaultPopupPlacement,
181+ OpenInNewPane: true,
182+ FollowPaneContext: true,
183+ },
184+ },
149 }185 }
150 }186 }
151 187
M services/config_service.go +16 -0
@@ -26,6 +26,7 @@ type ConfigInfo struct {
2626 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
2727 RenderingMode string `json:"rendering_mode"`
2828 UseDomZoom bool `json:"use_dom_zoom"`
29+ Workspace config.WorkspaceConfig `json:"workspace"`
2930 }
3031
3132 // NewConfigService creates a new ConfigService instance.
@@ -46,6 +47,7 @@ func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error)
4647 DmenuSettings: &s.config.Dmenu,
4748 RenderingMode: string(s.config.RenderingMode),
4849 UseDomZoom: s.config.UseDomZoom,
50+ Workspace: s.config.Workspace,
4951 }, nil
5052 }
5153
@@ -216,6 +218,20 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
216218 errors = append(errors, "dmenu max_history_items must be at least 1")
217219 }
218220
221+ // Validate workspace configuration when enabled
222+ if s.config.Workspace.EnableZellijControls {
223+ if strings.TrimSpace(s.config.Workspace.PaneMode.ActivationShortcut) == "" {
224+ errors = append(errors, "workspace pane_mode activation_shortcut is empty")
225+ }
226+ if len(s.config.Workspace.PaneMode.ActionBindings) == 0 {
227+ errors = append(errors, "workspace pane_mode action_bindings are empty")
228+ }
229+ }
230+
231+ if strings.TrimSpace(s.config.Workspace.Popups.Placement) == "" {
232+ errors = append(errors, "workspace popups placement is empty")
233+ }
234+
219235 return errors, nil
220236 }
221237
@@ -26,6 +26,7 @@ type ConfigInfo struct {
26 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`26 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
27 RenderingMode string `json:"rendering_mode"`27 RenderingMode string `json:"rendering_mode"`
28 UseDomZoom bool `json:"use_dom_zoom"`28 UseDomZoom bool `json:"use_dom_zoom"`
29+ Workspace config.WorkspaceConfig `json:"workspace"`
29 }30 }
30 31
31 // NewConfigService creates a new ConfigService instance.32 // NewConfigService creates a new ConfigService instance.
@@ -46,6 +47,7 @@ func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error)
46 DmenuSettings: &s.config.Dmenu,47 DmenuSettings: &s.config.Dmenu,
47 RenderingMode: string(s.config.RenderingMode),48 RenderingMode: string(s.config.RenderingMode),
48 UseDomZoom: s.config.UseDomZoom,49 UseDomZoom: s.config.UseDomZoom,
50+ Workspace: s.config.Workspace,
49 }, nil51 }, nil
50 }52 }
51 53
@@ -216,6 +218,20 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
216 errors = append(errors, "dmenu max_history_items must be at least 1")218 errors = append(errors, "dmenu max_history_items must be at least 1")
217 }219 }
218 220
221+ // Validate workspace configuration when enabled
222+ if s.config.Workspace.EnableZellijControls {
223+ if strings.TrimSpace(s.config.Workspace.PaneMode.ActivationShortcut) == "" {
224+ errors = append(errors, "workspace pane_mode activation_shortcut is empty")
225+ }
226+ if len(s.config.Workspace.PaneMode.ActionBindings) == 0 {
227+ errors = append(errors, "workspace pane_mode action_bindings are empty")
228+ }
229+ }
230+
231+ if strings.TrimSpace(s.config.Workspace.Popups.Placement) == "" {
232+ errors = append(errors, "workspace popups placement is empty")
233+ }
234+
219 return errors, nil235 return errors, nil
220 }236 }
221 237

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