detached 137ce9c

feat(ui): integrate pane management and keyboard handling into app

- Wire ManagePanesUseCase for split/close operations
- Create WorkspaceView per tab with TreeRenderer
- Connect keyboard handler with action dispatch
- Add pane split, close, and focus navigation handlers
- Fix env var prefix to DUMBER_ in config loader
commit
137ce9cf1c7723aa401faa0198473671ed71badc
author
brice · · 2025-12-09 21:52 UTC
committer
brice · · 2025-12-09 22:51 UTC
parent
94a9ba9
signature
unsigned
4 files · +397 -13
M go.mod +7 -2
@@ -10,9 +10,11 @@ require (
1010 github.com/pressly/goose/v3 v3.26.0
1111 github.com/rs/zerolog v1.33.0
1212 github.com/spf13/viper v1.21.0
13+ github.com/stretchr/testify v1.11.1
1314 )
1415
1516 require (
17+ github.com/davecgh/go-spew v1.1.1 // indirect
1618 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
1719 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
1820 github.com/mattn/go-colorable v0.1.13 // indirect
@@ -20,12 +22,14 @@ require (
2022 github.com/mfridman/interpolate v0.0.2 // indirect
2123 github.com/ncruces/julianday v1.0.0 // indirect
2224 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
25+ github.com/pmezard/go-difflib v1.0.0 // indirect
2326 github.com/sagikazarmark/locafero v0.11.0 // indirect
2427 github.com/sethvargo/go-retry v0.3.0 // indirect
2528 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
2629 github.com/spf13/afero v1.15.0 // indirect
2730 github.com/spf13/cast v1.10.0 // indirect
2831 github.com/spf13/pflag v1.0.10 // indirect
32+ github.com/stretchr/objx v0.5.2 // indirect
2933 github.com/subosito/gotenv v1.6.0 // indirect
3034 github.com/tetratelabs/wazero v1.10.1 // indirect
3135 go.uber.org/multierr v1.11.0 // indirect
@@ -33,9 +37,10 @@ require (
3337 golang.org/x/sync v0.18.0 // indirect
3438 golang.org/x/sys v0.38.0 // indirect
3539 golang.org/x/text v0.31.0 // indirect
40+ gopkg.in/yaml.v3 v3.0.1 // indirect
3641 )
3742
3843 replace (
39- github.com/bnema/puregotk-webkit => /home/brice/projects/puregotk-webkit
40- github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk
44+ github.com/bnema/puregotk-webkit => ../puregotk-webkit
45+ github.com/jwijenbergh/puregotk => ../puregotk
4146 )
@@ -10,9 +10,11 @@ require (
10 github.com/pressly/goose/v3 v3.26.010 github.com/pressly/goose/v3 v3.26.0
11 github.com/rs/zerolog v1.33.011 github.com/rs/zerolog v1.33.0
12 github.com/spf13/viper v1.21.012 github.com/spf13/viper v1.21.0
13+ github.com/stretchr/testify v1.11.1
13 )14 )
14 15
15 require (16 require (
17+ github.com/davecgh/go-spew v1.1.1 // indirect
16 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect18 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
17 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect19 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
18 github.com/mattn/go-colorable v0.1.13 // indirect20 github.com/mattn/go-colorable v0.1.13 // indirect
@@ -20,12 +22,14 @@ require (
20 github.com/mfridman/interpolate v0.0.2 // indirect22 github.com/mfridman/interpolate v0.0.2 // indirect
21 github.com/ncruces/julianday v1.0.0 // indirect23 github.com/ncruces/julianday v1.0.0 // indirect
22 github.com/pelletier/go-toml/v2 v2.2.4 // indirect24 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
25+ github.com/pmezard/go-difflib v1.0.0 // indirect
23 github.com/sagikazarmark/locafero v0.11.0 // indirect26 github.com/sagikazarmark/locafero v0.11.0 // indirect
24 github.com/sethvargo/go-retry v0.3.0 // indirect27 github.com/sethvargo/go-retry v0.3.0 // indirect
25 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect28 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
26 github.com/spf13/afero v1.15.0 // indirect29 github.com/spf13/afero v1.15.0 // indirect
27 github.com/spf13/cast v1.10.0 // indirect30 github.com/spf13/cast v1.10.0 // indirect
28 github.com/spf13/pflag v1.0.10 // indirect31 github.com/spf13/pflag v1.0.10 // indirect
32+ github.com/stretchr/objx v0.5.2 // indirect
29 github.com/subosito/gotenv v1.6.0 // indirect33 github.com/subosito/gotenv v1.6.0 // indirect
30 github.com/tetratelabs/wazero v1.10.1 // indirect34 github.com/tetratelabs/wazero v1.10.1 // indirect
31 go.uber.org/multierr v1.11.0 // indirect35 go.uber.org/multierr v1.11.0 // indirect
@@ -33,9 +37,10 @@ require (
33 golang.org/x/sync v0.18.0 // indirect37 golang.org/x/sync v0.18.0 // indirect
34 golang.org/x/sys v0.38.0 // indirect38 golang.org/x/sys v0.38.0 // indirect
35 golang.org/x/text v0.31.0 // indirect39 golang.org/x/text v0.31.0 // indirect
40+ gopkg.in/yaml.v3 v3.0.1 // indirect
36 )41 )
37 42
38 replace (43 replace (
39- github.com/bnema/puregotk-webkit => /home/brice/projects/puregotk-webkit44+ github.com/bnema/puregotk-webkit => ../puregotk-webkit
40- github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk45+ github.com/jwijenbergh/puregotk => ../puregotk
41 )46 )
M go.sum +2 -0 generated file: collapsed
M internal/infrastructure/config/loader.go +5 -5
@@ -25,8 +25,8 @@ func NewManager() (*Manager, error) {
2525 v := viper.New()
2626
2727 // Configure Viper for TOML as default format
28- v.SetConfigName("config") // Name without extension
29- v.SetConfigType("toml") // TOML as default format
28+ v.SetConfigName("config") // Name without extension
29+ v.SetConfigType("toml") // TOML as default format
3030
3131 // Add config paths
3232 configDir, err := GetConfigDir()
@@ -37,15 +37,15 @@ func NewManager() (*Manager, error) {
3737 v.AddConfigPath(".") // Current directory for development
3838
3939 // Set up environment variable support
40- v.SetEnvPrefix("DUMB_BROWSER")
40+ v.SetEnvPrefix("DUMBER")
4141 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
4242 v.AutomaticEnv()
4343
4444 // Note: Most environment variables are handled automatically via AutomaticEnv()
45- // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
45+ // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
4646 // The explicit bindings below are only for special cases with different naming patterns.
4747
48- // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
48+ // Explicit bindings for legacy or system env vars
4949 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
5050 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
5151 }
@@ -25,8 +25,8 @@ func NewManager() (*Manager, error) {
25 v := viper.New()25 v := viper.New()
26 26
27 // Configure Viper for TOML as default format27 // Configure Viper for TOML as default format
28- v.SetConfigName("config") // Name without extension28+ v.SetConfigName("config") // Name without extension
29- v.SetConfigType("toml") // TOML as default format29+ v.SetConfigType("toml") // TOML as default format
30 30
31 // Add config paths31 // Add config paths
32 configDir, err := GetConfigDir()32 configDir, err := GetConfigDir()
@@ -37,15 +37,15 @@ func NewManager() (*Manager, error) {
37 v.AddConfigPath(".") // Current directory for development37 v.AddConfigPath(".") // Current directory for development
38 38
39 // Set up environment variable support39 // Set up environment variable support
40- v.SetEnvPrefix("DUMB_BROWSER")40+ v.SetEnvPrefix("DUMBER")
41 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))41 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
42 v.AutomaticEnv()42 v.AutomaticEnv()
43 43
44 // Note: Most environment variables are handled automatically via AutomaticEnv()44 // Note: Most environment variables are handled automatically via AutomaticEnv()
45- // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).45+ // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
46 // The explicit bindings below are only for special cases with different naming patterns.46 // The explicit bindings below are only for special cases with different naming patterns.
47 47
48- // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)48+ // Explicit bindings for legacy or system env vars
49 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {49 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
50 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)50 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
51 }51 }
M internal/ui/app.go +383 -6
@@ -8,6 +8,9 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/input"
13+ "github.com/bnema/dumber/internal/ui/layout"
1114 "github.com/bnema/dumber/internal/ui/window"
1215 "github.com/jwijenbergh/puregotk/v4/gio"
1316 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -29,6 +32,14 @@ type App struct {
2932 tabs *entity.TabList
3033 tabsUC *usecase.ManageTabsUseCase
3134
35+ // Pane management
36+ panesUC *usecase.ManagePanesUseCase
37+ workspaceViews map[entity.TabID]*component.WorkspaceView
38+ widgetFactory layout.WidgetFactory
39+
40+ // Input handling
41+ keyboardHandler *input.KeyboardHandler
42+
3243 // ID generator for tabs/panes
3344 idCounter uint64
3445 idMu sync.Mutex
@@ -49,12 +60,14 @@ func New(deps *Dependencies) (*App, error) {
4960 logger := logging.FromContext(ctx)
5061
5162 app := &App{
52- deps: deps,
53- tabs: entity.NewTabList(),
54- tabsUC: deps.TabsUC,
55- ctx: ctx,
56- cancel: cancel,
57- logger: logger,
63+ deps: deps,
64+ tabs: entity.NewTabList(),
65+ tabsUC: deps.TabsUC,
66+ panesUC: deps.PanesUC,
67+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
68+ ctx: ctx,
69+ cancel: cancel,
70+ logger: logger,
5871 }
5972
6073 return app, nil
@@ -102,6 +115,19 @@ func (a *App) onActivate() {
102115 return
103116 }
104117
118+ // Initialize widget factory for pane layout
119+ a.widgetFactory = layout.NewGtkWidgetFactory()
120+
121+ // Create keyboard handler
122+ a.keyboardHandler = input.NewKeyboardHandler(
123+ a.ctx,
124+ &a.deps.Config.Workspace,
125+ a.logger,
126+ )
127+ a.keyboardHandler.SetOnAction(a.handleKeyboardAction)
128+ a.keyboardHandler.SetOnModeChange(a.handleModeChange)
129+ a.keyboardHandler.AttachTo(a.mainWindow.Window())
130+
105131 // Create an initial tab
106132 a.createInitialTab()
107133
@@ -149,6 +175,9 @@ func (a *App) createInitialTab() {
149175 a.mainWindow.TabBar().SetActive(output.Tab.ID)
150176 }
151177
178+ // Create workspace view for this tab
179+ a.createWorkspaceView(output.Tab)
180+
152181 a.logger.Debug().
153182 Str("tab_id", string(output.Tab.ID)).
154183 Msg("initial tab created")
@@ -190,3 +219,351 @@ func RunWithArgs(deps *Dependencies) int {
190219 }
191220 return app.Run(os.Args)
192221 }
222+
223+// handleKeyboardAction dispatches keyboard actions to appropriate handlers.
224+func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) error {
225+ a.logger.Debug().
226+ Str("action", string(action)).
227+ Msg("handling keyboard action")
228+
229+ switch action {
230+ // Tab actions
231+ case input.ActionNewTab:
232+ return a.handleNewTab()
233+ case input.ActionCloseTab:
234+ return a.handleCloseTab()
235+ case input.ActionNextTab:
236+ return a.handleNextTab()
237+ case input.ActionPreviousTab:
238+ return a.handlePreviousTab()
239+
240+ // Pane actions
241+ case input.ActionSplitRight:
242+ return a.handlePaneSplit(usecase.SplitRight)
243+ case input.ActionSplitLeft:
244+ return a.handlePaneSplit(usecase.SplitLeft)
245+ case input.ActionSplitUp:
246+ return a.handlePaneSplit(usecase.SplitUp)
247+ case input.ActionSplitDown:
248+ return a.handlePaneSplit(usecase.SplitDown)
249+ case input.ActionClosePane:
250+ return a.handleClosePane()
251+ case input.ActionFocusRight:
252+ return a.handlePaneFocus(usecase.NavRight)
253+ case input.ActionFocusLeft:
254+ return a.handlePaneFocus(usecase.NavLeft)
255+ case input.ActionFocusUp:
256+ return a.handlePaneFocus(usecase.NavUp)
257+ case input.ActionFocusDown:
258+ return a.handlePaneFocus(usecase.NavDown)
259+
260+ // Navigation (stub implementations for now)
261+ case input.ActionGoBack:
262+ a.logger.Debug().Msg("go back action (not yet implemented)")
263+ case input.ActionGoForward:
264+ a.logger.Debug().Msg("go forward action (not yet implemented)")
265+ case input.ActionReload:
266+ a.logger.Debug().Msg("reload action (not yet implemented)")
267+ case input.ActionHardReload:
268+ a.logger.Debug().Msg("hard reload action (not yet implemented)")
269+
270+ // Zoom (stub implementations for now)
271+ case input.ActionZoomIn:
272+ a.logger.Debug().Msg("zoom in action (not yet implemented)")
273+ case input.ActionZoomOut:
274+ a.logger.Debug().Msg("zoom out action (not yet implemented)")
275+ case input.ActionZoomReset:
276+ a.logger.Debug().Msg("zoom reset action (not yet implemented)")
277+
278+ // UI
279+ case input.ActionOpenOmnibox:
280+ a.logger.Debug().Msg("open omnibox action (not yet implemented)")
281+ case input.ActionOpenDevTools:
282+ a.logger.Debug().Msg("open devtools action (not yet implemented)")
283+ case input.ActionToggleFullscreen:
284+ a.logger.Debug().Msg("toggle fullscreen action (not yet implemented)")
285+
286+ // Application
287+ case input.ActionQuit:
288+ a.Quit()
289+
290+ default:
291+ a.logger.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
292+ }
293+
294+ return nil
295+}
296+
297+// handleModeChange is called when the input mode changes.
298+func (a *App) handleModeChange(from, to input.Mode) {
299+ a.logger.Debug().
300+ Str("from", from.String()).
301+ Str("to", to.String()).
302+ Msg("input mode changed")
303+
304+ // TODO: Update UI to indicate mode (e.g., change border color)
305+}
306+
307+// handleNewTab creates a new tab.
308+func (a *App) handleNewTab() error {
309+ output, err := a.tabsUC.Create(a.ctx, usecase.CreateTabInput{
310+ TabList: a.tabs,
311+ Name: "",
312+ InitialURL: "about:blank",
313+ })
314+ if err != nil {
315+ return err
316+ }
317+
318+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
319+ a.mainWindow.TabBar().AddTab(output.Tab)
320+ a.mainWindow.TabBar().SetActive(output.Tab.ID)
321+ }
322+
323+ a.logger.Debug().
324+ Str("tab_id", string(output.Tab.ID)).
325+ Msg("new tab created")
326+
327+ return nil
328+}
329+
330+// handleCloseTab closes the active tab.
331+func (a *App) handleCloseTab() error {
332+ activeID := a.tabs.ActiveTabID
333+ if activeID == "" {
334+ return nil
335+ }
336+
337+ wasLast, err := a.tabsUC.Close(a.ctx, a.tabs, activeID)
338+ if err != nil {
339+ return err
340+ }
341+
342+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
343+ a.mainWindow.TabBar().RemoveTab(activeID)
344+
345+ // Switch to new active tab if any
346+ if a.tabs.ActiveTabID != "" {
347+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
348+ }
349+ }
350+
351+ // Quit if no tabs left
352+ if wasLast {
353+ a.Quit()
354+ }
355+
356+ return nil
357+}
358+
359+// handleNextTab switches to the next tab.
360+func (a *App) handleNextTab() error {
361+ if err := a.tabsUC.SwitchNext(a.ctx, a.tabs); err != nil {
362+ return err
363+ }
364+
365+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
366+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
367+ }
368+
369+ return nil
370+}
371+
372+// handlePreviousTab switches to the previous tab.
373+func (a *App) handlePreviousTab() error {
374+ if err := a.tabsUC.SwitchPrevious(a.ctx, a.tabs); err != nil {
375+ return err
376+ }
377+
378+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
379+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
380+ }
381+
382+ return nil
383+}
384+
385+// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
386+func (a *App) createWorkspaceView(tab *entity.Tab) {
387+ if a.widgetFactory == nil {
388+ a.logger.Error().Msg("widget factory not initialized")
389+ return
390+ }
391+
392+ // Create workspace view
393+ wsView := component.NewWorkspaceView(a.widgetFactory)
394+ if wsView == nil {
395+ a.logger.Error().Msg("failed to create workspace view")
396+ return
397+ }
398+
399+ // Set the workspace
400+ if err := wsView.SetWorkspace(tab.Workspace); err != nil {
401+ a.logger.Error().Err(err).Msg("failed to set workspace in view")
402+ return
403+ }
404+
405+ // Store in map
406+ a.workspaceViews[tab.ID] = wsView
407+
408+ // Attach to content area
409+ if a.mainWindow != nil {
410+ widget := wsView.Widget()
411+ if widget != nil {
412+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
413+ a.mainWindow.SetContent(gtkWidget)
414+ }
415+ }
416+ }
417+
418+ a.logger.Debug().
419+ Str("tab_id", string(tab.ID)).
420+ Msg("workspace view created and attached")
421+}
422+
423+// activeWorkspace returns the workspace of the active tab.
424+func (a *App) activeWorkspace() *entity.Workspace {
425+ activeTab := a.tabs.ActiveTab()
426+ if activeTab == nil {
427+ return nil
428+ }
429+ return activeTab.Workspace
430+}
431+
432+// activeWorkspaceView returns the workspace view for the active tab.
433+func (a *App) activeWorkspaceView() *component.WorkspaceView {
434+ activeTab := a.tabs.ActiveTab()
435+ if activeTab == nil {
436+ return nil
437+ }
438+ return a.workspaceViews[activeTab.ID]
439+}
440+
441+// handlePaneSplit splits the active pane in the given direction.
442+func (a *App) handlePaneSplit(direction usecase.SplitDirection) error {
443+ if a.panesUC == nil {
444+ a.logger.Warn().Msg("panes use case not available")
445+ return nil
446+ }
447+
448+ ws := a.activeWorkspace()
449+ if ws == nil {
450+ a.logger.Warn().Msg("no active workspace")
451+ return nil
452+ }
453+
454+ activePane := ws.ActivePane()
455+ if activePane == nil {
456+ a.logger.Warn().Msg("no active pane to split")
457+ return nil
458+ }
459+
460+ output, err := a.panesUC.Split(a.ctx, usecase.SplitPaneInput{
461+ Workspace: ws,
462+ TargetPane: activePane,
463+ Direction: direction,
464+ })
465+ if err != nil {
466+ a.logger.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
467+ return err
468+ }
469+
470+ // Set the new pane as active
471+ ws.ActivePaneID = output.NewPaneNode.Pane.ID
472+
473+ // Rebuild the workspace view
474+ wsView := a.activeWorkspaceView()
475+ if wsView != nil {
476+ if err := wsView.Rebuild(); err != nil {
477+ a.logger.Error().Err(err).Msg("failed to rebuild workspace view")
478+ }
479+ }
480+
481+ a.logger.Info().
482+ Str("direction", string(direction)).
483+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
484+ Msg("pane split completed")
485+
486+ return nil
487+}
488+
489+// handleClosePane closes the active pane.
490+func (a *App) handleClosePane() error {
491+ if a.panesUC == nil {
492+ a.logger.Warn().Msg("panes use case not available")
493+ return nil
494+ }
495+
496+ ws := a.activeWorkspace()
497+ if ws == nil {
498+ a.logger.Warn().Msg("no active workspace")
499+ return nil
500+ }
501+
502+ activePane := ws.ActivePane()
503+ if activePane == nil {
504+ a.logger.Warn().Msg("no active pane to close")
505+ return nil
506+ }
507+
508+ // Don't close the last pane - close the tab instead
509+ if ws.PaneCount() <= 1 {
510+ return a.handleCloseTab()
511+ }
512+
513+ _, err := a.panesUC.Close(a.ctx, ws, activePane)
514+ if err != nil {
515+ a.logger.Error().Err(err).Msg("failed to close pane")
516+ return err
517+ }
518+
519+ // Rebuild the workspace view
520+ wsView := a.activeWorkspaceView()
521+ if wsView != nil {
522+ if err := wsView.Rebuild(); err != nil {
523+ a.logger.Error().Err(err).Msg("failed to rebuild workspace view")
524+ }
525+ }
526+
527+ a.logger.Info().Msg("pane closed")
528+ return nil
529+}
530+
531+// handlePaneFocus navigates focus to an adjacent pane.
532+func (a *App) handlePaneFocus(direction usecase.NavigateDirection) error {
533+ if a.panesUC == nil {
534+ a.logger.Warn().Msg("panes use case not available")
535+ return nil
536+ }
537+
538+ ws := a.activeWorkspace()
539+ if ws == nil {
540+ a.logger.Warn().Msg("no active workspace")
541+ return nil
542+ }
543+
544+ newPane, err := a.panesUC.NavigateFocus(a.ctx, ws, direction)
545+ if err != nil {
546+ a.logger.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
547+ return err
548+ }
549+
550+ if newPane == nil {
551+ a.logger.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
552+ return nil
553+ }
554+
555+ // Update the workspace view's active pane
556+ wsView := a.activeWorkspaceView()
557+ if wsView != nil {
558+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
559+ a.logger.Warn().Err(err).Msg("failed to update active pane in view")
560+ }
561+ }
562+
563+ a.logger.Debug().
564+ Str("direction", string(direction)).
565+ Str("new_pane_id", newPane.ID).
566+ Msg("focus navigated")
567+
568+ return nil
569+}
@@ -8,6 +8,9 @@ import (
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/input"
13+ "github.com/bnema/dumber/internal/ui/layout"
11 "github.com/bnema/dumber/internal/ui/window"14 "github.com/bnema/dumber/internal/ui/window"
12 "github.com/jwijenbergh/puregotk/v4/gio"15 "github.com/jwijenbergh/puregotk/v4/gio"
13 "github.com/jwijenbergh/puregotk/v4/gtk"16 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -29,6 +32,14 @@ type App struct {
29 tabs *entity.TabList32 tabs *entity.TabList
30 tabsUC *usecase.ManageTabsUseCase33 tabsUC *usecase.ManageTabsUseCase
31 34
35+ // Pane management
36+ panesUC *usecase.ManagePanesUseCase
37+ workspaceViews map[entity.TabID]*component.WorkspaceView
38+ widgetFactory layout.WidgetFactory
39+
40+ // Input handling
41+ keyboardHandler *input.KeyboardHandler
42+
32 // ID generator for tabs/panes43 // ID generator for tabs/panes
33 idCounter uint6444 idCounter uint64
34 idMu sync.Mutex45 idMu sync.Mutex
@@ -49,12 +60,14 @@ func New(deps *Dependencies) (*App, error) {
49 logger := logging.FromContext(ctx)60 logger := logging.FromContext(ctx)
50 61
51 app := &App{62 app := &App{
52- deps: deps,63+ deps: deps,
53- tabs: entity.NewTabList(),64+ tabs: entity.NewTabList(),
54- tabsUC: deps.TabsUC,65+ tabsUC: deps.TabsUC,
55- ctx: ctx,66+ panesUC: deps.PanesUC,
56- cancel: cancel,67+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
57- logger: logger,68+ ctx: ctx,
69+ cancel: cancel,
70+ logger: logger,
58 }71 }
59 72
60 return app, nil73 return app, nil
@@ -102,6 +115,19 @@ func (a *App) onActivate() {
102 return115 return
103 }116 }
104 117
118+ // Initialize widget factory for pane layout
119+ a.widgetFactory = layout.NewGtkWidgetFactory()
120+
121+ // Create keyboard handler
122+ a.keyboardHandler = input.NewKeyboardHandler(
123+ a.ctx,
124+ &a.deps.Config.Workspace,
125+ a.logger,
126+ )
127+ a.keyboardHandler.SetOnAction(a.handleKeyboardAction)
128+ a.keyboardHandler.SetOnModeChange(a.handleModeChange)
129+ a.keyboardHandler.AttachTo(a.mainWindow.Window())
130+
105 // Create an initial tab131 // Create an initial tab
106 a.createInitialTab()132 a.createInitialTab()
107 133
@@ -149,6 +175,9 @@ func (a *App) createInitialTab() {
149 a.mainWindow.TabBar().SetActive(output.Tab.ID)175 a.mainWindow.TabBar().SetActive(output.Tab.ID)
150 }176 }
151 177
178+ // Create workspace view for this tab
179+ a.createWorkspaceView(output.Tab)
180+
152 a.logger.Debug().181 a.logger.Debug().
153 Str("tab_id", string(output.Tab.ID)).182 Str("tab_id", string(output.Tab.ID)).
154 Msg("initial tab created")183 Msg("initial tab created")
@@ -190,3 +219,351 @@ func RunWithArgs(deps *Dependencies) int {
190 }219 }
191 return app.Run(os.Args)220 return app.Run(os.Args)
192 }221 }
222+
223+// handleKeyboardAction dispatches keyboard actions to appropriate handlers.
224+func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) error {
225+ a.logger.Debug().
226+ Str("action", string(action)).
227+ Msg("handling keyboard action")
228+
229+ switch action {
230+ // Tab actions
231+ case input.ActionNewTab:
232+ return a.handleNewTab()
233+ case input.ActionCloseTab:
234+ return a.handleCloseTab()
235+ case input.ActionNextTab:
236+ return a.handleNextTab()
237+ case input.ActionPreviousTab:
238+ return a.handlePreviousTab()
239+
240+ // Pane actions
241+ case input.ActionSplitRight:
242+ return a.handlePaneSplit(usecase.SplitRight)
243+ case input.ActionSplitLeft:
244+ return a.handlePaneSplit(usecase.SplitLeft)
245+ case input.ActionSplitUp:
246+ return a.handlePaneSplit(usecase.SplitUp)
247+ case input.ActionSplitDown:
248+ return a.handlePaneSplit(usecase.SplitDown)
249+ case input.ActionClosePane:
250+ return a.handleClosePane()
251+ case input.ActionFocusRight:
252+ return a.handlePaneFocus(usecase.NavRight)
253+ case input.ActionFocusLeft:
254+ return a.handlePaneFocus(usecase.NavLeft)
255+ case input.ActionFocusUp:
256+ return a.handlePaneFocus(usecase.NavUp)
257+ case input.ActionFocusDown:
258+ return a.handlePaneFocus(usecase.NavDown)
259+
260+ // Navigation (stub implementations for now)
261+ case input.ActionGoBack:
262+ a.logger.Debug().Msg("go back action (not yet implemented)")
263+ case input.ActionGoForward:
264+ a.logger.Debug().Msg("go forward action (not yet implemented)")
265+ case input.ActionReload:
266+ a.logger.Debug().Msg("reload action (not yet implemented)")
267+ case input.ActionHardReload:
268+ a.logger.Debug().Msg("hard reload action (not yet implemented)")
269+
270+ // Zoom (stub implementations for now)
271+ case input.ActionZoomIn:
272+ a.logger.Debug().Msg("zoom in action (not yet implemented)")
273+ case input.ActionZoomOut:
274+ a.logger.Debug().Msg("zoom out action (not yet implemented)")
275+ case input.ActionZoomReset:
276+ a.logger.Debug().Msg("zoom reset action (not yet implemented)")
277+
278+ // UI
279+ case input.ActionOpenOmnibox:
280+ a.logger.Debug().Msg("open omnibox action (not yet implemented)")
281+ case input.ActionOpenDevTools:
282+ a.logger.Debug().Msg("open devtools action (not yet implemented)")
283+ case input.ActionToggleFullscreen:
284+ a.logger.Debug().Msg("toggle fullscreen action (not yet implemented)")
285+
286+ // Application
287+ case input.ActionQuit:
288+ a.Quit()
289+
290+ default:
291+ a.logger.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
292+ }
293+
294+ return nil
295+}
296+
297+// handleModeChange is called when the input mode changes.
298+func (a *App) handleModeChange(from, to input.Mode) {
299+ a.logger.Debug().
300+ Str("from", from.String()).
301+ Str("to", to.String()).
302+ Msg("input mode changed")
303+
304+ // TODO: Update UI to indicate mode (e.g., change border color)
305+}
306+
307+// handleNewTab creates a new tab.
308+func (a *App) handleNewTab() error {
309+ output, err := a.tabsUC.Create(a.ctx, usecase.CreateTabInput{
310+ TabList: a.tabs,
311+ Name: "",
312+ InitialURL: "about:blank",
313+ })
314+ if err != nil {
315+ return err
316+ }
317+
318+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
319+ a.mainWindow.TabBar().AddTab(output.Tab)
320+ a.mainWindow.TabBar().SetActive(output.Tab.ID)
321+ }
322+
323+ a.logger.Debug().
324+ Str("tab_id", string(output.Tab.ID)).
325+ Msg("new tab created")
326+
327+ return nil
328+}
329+
330+// handleCloseTab closes the active tab.
331+func (a *App) handleCloseTab() error {
332+ activeID := a.tabs.ActiveTabID
333+ if activeID == "" {
334+ return nil
335+ }
336+
337+ wasLast, err := a.tabsUC.Close(a.ctx, a.tabs, activeID)
338+ if err != nil {
339+ return err
340+ }
341+
342+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
343+ a.mainWindow.TabBar().RemoveTab(activeID)
344+
345+ // Switch to new active tab if any
346+ if a.tabs.ActiveTabID != "" {
347+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
348+ }
349+ }
350+
351+ // Quit if no tabs left
352+ if wasLast {
353+ a.Quit()
354+ }
355+
356+ return nil
357+}
358+
359+// handleNextTab switches to the next tab.
360+func (a *App) handleNextTab() error {
361+ if err := a.tabsUC.SwitchNext(a.ctx, a.tabs); err != nil {
362+ return err
363+ }
364+
365+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
366+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
367+ }
368+
369+ return nil
370+}
371+
372+// handlePreviousTab switches to the previous tab.
373+func (a *App) handlePreviousTab() error {
374+ if err := a.tabsUC.SwitchPrevious(a.ctx, a.tabs); err != nil {
375+ return err
376+ }
377+
378+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
379+ a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
380+ }
381+
382+ return nil
383+}
384+
385+// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
386+func (a *App) createWorkspaceView(tab *entity.Tab) {
387+ if a.widgetFactory == nil {
388+ a.logger.Error().Msg("widget factory not initialized")
389+ return
390+ }
391+
392+ // Create workspace view
393+ wsView := component.NewWorkspaceView(a.widgetFactory)
394+ if wsView == nil {
395+ a.logger.Error().Msg("failed to create workspace view")
396+ return
397+ }
398+
399+ // Set the workspace
400+ if err := wsView.SetWorkspace(tab.Workspace); err != nil {
401+ a.logger.Error().Err(err).Msg("failed to set workspace in view")
402+ return
403+ }
404+
405+ // Store in map
406+ a.workspaceViews[tab.ID] = wsView
407+
408+ // Attach to content area
409+ if a.mainWindow != nil {
410+ widget := wsView.Widget()
411+ if widget != nil {
412+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
413+ a.mainWindow.SetContent(gtkWidget)
414+ }
415+ }
416+ }
417+
418+ a.logger.Debug().
419+ Str("tab_id", string(tab.ID)).
420+ Msg("workspace view created and attached")
421+}
422+
423+// activeWorkspace returns the workspace of the active tab.
424+func (a *App) activeWorkspace() *entity.Workspace {
425+ activeTab := a.tabs.ActiveTab()
426+ if activeTab == nil {
427+ return nil
428+ }
429+ return activeTab.Workspace
430+}
431+
432+// activeWorkspaceView returns the workspace view for the active tab.
433+func (a *App) activeWorkspaceView() *component.WorkspaceView {
434+ activeTab := a.tabs.ActiveTab()
435+ if activeTab == nil {
436+ return nil
437+ }
438+ return a.workspaceViews[activeTab.ID]
439+}
440+
441+// handlePaneSplit splits the active pane in the given direction.
442+func (a *App) handlePaneSplit(direction usecase.SplitDirection) error {
443+ if a.panesUC == nil {
444+ a.logger.Warn().Msg("panes use case not available")
445+ return nil
446+ }
447+
448+ ws := a.activeWorkspace()
449+ if ws == nil {
450+ a.logger.Warn().Msg("no active workspace")
451+ return nil
452+ }
453+
454+ activePane := ws.ActivePane()
455+ if activePane == nil {
456+ a.logger.Warn().Msg("no active pane to split")
457+ return nil
458+ }
459+
460+ output, err := a.panesUC.Split(a.ctx, usecase.SplitPaneInput{
461+ Workspace: ws,
462+ TargetPane: activePane,
463+ Direction: direction,
464+ })
465+ if err != nil {
466+ a.logger.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
467+ return err
468+ }
469+
470+ // Set the new pane as active
471+ ws.ActivePaneID = output.NewPaneNode.Pane.ID
472+
473+ // Rebuild the workspace view
474+ wsView := a.activeWorkspaceView()
475+ if wsView != nil {
476+ if err := wsView.Rebuild(); err != nil {
477+ a.logger.Error().Err(err).Msg("failed to rebuild workspace view")
478+ }
479+ }
480+
481+ a.logger.Info().
482+ Str("direction", string(direction)).
483+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
484+ Msg("pane split completed")
485+
486+ return nil
487+}
488+
489+// handleClosePane closes the active pane.
490+func (a *App) handleClosePane() error {
491+ if a.panesUC == nil {
492+ a.logger.Warn().Msg("panes use case not available")
493+ return nil
494+ }
495+
496+ ws := a.activeWorkspace()
497+ if ws == nil {
498+ a.logger.Warn().Msg("no active workspace")
499+ return nil
500+ }
501+
502+ activePane := ws.ActivePane()
503+ if activePane == nil {
504+ a.logger.Warn().Msg("no active pane to close")
505+ return nil
506+ }
507+
508+ // Don't close the last pane - close the tab instead
509+ if ws.PaneCount() <= 1 {
510+ return a.handleCloseTab()
511+ }
512+
513+ _, err := a.panesUC.Close(a.ctx, ws, activePane)
514+ if err != nil {
515+ a.logger.Error().Err(err).Msg("failed to close pane")
516+ return err
517+ }
518+
519+ // Rebuild the workspace view
520+ wsView := a.activeWorkspaceView()
521+ if wsView != nil {
522+ if err := wsView.Rebuild(); err != nil {
523+ a.logger.Error().Err(err).Msg("failed to rebuild workspace view")
524+ }
525+ }
526+
527+ a.logger.Info().Msg("pane closed")
528+ return nil
529+}
530+
531+// handlePaneFocus navigates focus to an adjacent pane.
532+func (a *App) handlePaneFocus(direction usecase.NavigateDirection) error {
533+ if a.panesUC == nil {
534+ a.logger.Warn().Msg("panes use case not available")
535+ return nil
536+ }
537+
538+ ws := a.activeWorkspace()
539+ if ws == nil {
540+ a.logger.Warn().Msg("no active workspace")
541+ return nil
542+ }
543+
544+ newPane, err := a.panesUC.NavigateFocus(a.ctx, ws, direction)
545+ if err != nil {
546+ a.logger.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
547+ return err
548+ }
549+
550+ if newPane == nil {
551+ a.logger.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
552+ return nil
553+ }
554+
555+ // Update the workspace view's active pane
556+ wsView := a.activeWorkspaceView()
557+ if wsView != nil {
558+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
559+ a.logger.Warn().Err(err).Msg("failed to update active pane in view")
560+ }
561+ }
562+
563+ a.logger.Debug().
564+ Str("direction", string(direction)).
565+ Str("new_pane_id", newPane.ID).
566+ Msg("focus navigated")
567+
568+ return nil
569+}

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