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 ( | |||
| 10 | 10 | github.com/pressly/goose/v3 v3.26.0 | |
| 11 | 11 | github.com/rs/zerolog v1.33.0 | |
| 12 | 12 | github.com/spf13/viper v1.21.0 | |
| 13 | + | github.com/stretchr/testify v1.11.1 | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | require ( | |
| 17 | + | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 16 | 18 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 17 | 19 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | |
| 18 | 20 | github.com/mattn/go-colorable v0.1.13 // indirect | |
| @@ -20,12 +22,14 @@ require ( | |||
| 20 | 22 | github.com/mfridman/interpolate v0.0.2 // indirect | |
| 21 | 23 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 22 | 24 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 25 | + | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 23 | 26 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 24 | 27 | github.com/sethvargo/go-retry v0.3.0 // indirect | |
| 25 | 28 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 26 | 29 | github.com/spf13/afero v1.15.0 // indirect | |
| 27 | 30 | github.com/spf13/cast v1.10.0 // indirect | |
| 28 | 31 | github.com/spf13/pflag v1.0.10 // indirect | |
| 32 | + | github.com/stretchr/objx v0.5.2 // indirect | |
| 29 | 33 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 30 | 34 | github.com/tetratelabs/wazero v1.10.1 // indirect | |
| 31 | 35 | go.uber.org/multierr v1.11.0 // indirect | |
| @@ -33,9 +37,10 @@ require ( | |||
| 33 | 37 | golang.org/x/sync v0.18.0 // indirect | |
| 34 | 38 | golang.org/x/sys v0.38.0 // indirect | |
| 35 | 39 | golang.org/x/text v0.31.0 // indirect | |
| 40 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 36 | 41 | ) | |
| 37 | 42 | ||
| 38 | 43 | 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 | |
| 41 | 46 | ) | |
| @@ -10,9 +10,11 @@ require ( | |||||
| 10 | github.com/pressly/goose/v3 v3.26.0 | 10 | github.com/pressly/goose/v3 v3.26.0 | ||
| 11 | github.com/rs/zerolog v1.33.0 | 11 | github.com/rs/zerolog v1.33.0 | ||
| 12 | github.com/spf13/viper v1.21.0 | 12 | 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 // indirect | 18 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 17 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | 19 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | ||
| 18 | github.com/mattn/go-colorable v0.1.13 // indirect | 20 | github.com/mattn/go-colorable v0.1.13 // indirect | ||
| @@ -20,12 +22,14 @@ require ( | |||||
| 20 | github.com/mfridman/interpolate v0.0.2 // indirect | 22 | github.com/mfridman/interpolate v0.0.2 // indirect | ||
| 21 | github.com/ncruces/julianday v1.0.0 // indirect | 23 | github.com/ncruces/julianday v1.0.0 // indirect | ||
| 22 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 24 | 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 // indirect | 26 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| 24 | github.com/sethvargo/go-retry v0.3.0 // indirect | 27 | github.com/sethvargo/go-retry v0.3.0 // indirect | ||
| 25 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 28 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 26 | github.com/spf13/afero v1.15.0 // indirect | 29 | github.com/spf13/afero v1.15.0 // indirect | ||
| 27 | github.com/spf13/cast v1.10.0 // indirect | 30 | github.com/spf13/cast v1.10.0 // indirect | ||
| 28 | github.com/spf13/pflag v1.0.10 // indirect | 31 | 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 // indirect | 33 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 30 | github.com/tetratelabs/wazero v1.10.1 // indirect | 34 | github.com/tetratelabs/wazero v1.10.1 // indirect | ||
| 31 | go.uber.org/multierr v1.11.0 // indirect | 35 | go.uber.org/multierr v1.11.0 // indirect | ||
| @@ -33,9 +37,10 @@ require ( | |||||
| 33 | golang.org/x/sync v0.18.0 // indirect | 37 | golang.org/x/sync v0.18.0 // indirect | ||
| 34 | golang.org/x/sys v0.38.0 // indirect | 38 | golang.org/x/sys v0.38.0 // indirect | ||
| 35 | golang.org/x/text v0.31.0 // indirect | 39 | 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-webkit | 44 | + | github.com/bnema/puregotk-webkit => ../puregotk-webkit |
| 40 | - | github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk | 45 | + | github.com/jwijenbergh/puregotk => ../puregotk |
| 41 | ) | 46 | ) | ||
M go.sum +2 -0
| @@ -62,6 +62,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||
| 62 | 62 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 63 | 63 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | |
| 64 | 64 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | |
| 65 | + | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | |
| 66 | + | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | |
| 65 | 67 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | |
| 66 | 68 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 67 | 69 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | |
| @@ -62,6 +62,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||||
| 62 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 62 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| 63 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | 63 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | ||
| 64 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | 64 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | ||
| 65 | + | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | |||
| 66 | + | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | |||
| 65 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | 67 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||
| 66 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | 68 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||
| 67 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | 69 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | ||
M internal/infrastructure/config/loader.go +5 -5
| @@ -25,8 +25,8 @@ func NewManager() (*Manager, error) { | |||
| 25 | 25 | v := viper.New() | |
| 26 | 26 | ||
| 27 | 27 | // 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 | |
| 30 | 30 | ||
| 31 | 31 | // Add config paths | |
| 32 | 32 | configDir, err := GetConfigDir() | |
| @@ -37,15 +37,15 @@ func NewManager() (*Manager, error) { | |||
| 37 | 37 | v.AddConfigPath(".") // Current directory for development | |
| 38 | 38 | ||
| 39 | 39 | // Set up environment variable support | |
| 40 | - | v.SetEnvPrefix("DUMB_BROWSER") | |
| 40 | + | v.SetEnvPrefix("DUMBER") | |
| 41 | 41 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |
| 42 | 42 | v.AutomaticEnv() | |
| 43 | 43 | ||
| 44 | 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 | 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 | 49 | if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil { | |
| 50 | 50 | return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err) | |
| 51 | 51 | } | |
| @@ -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 format | 27 | // Configure Viper for TOML as default format | ||
| 28 | - | v.SetConfigName("config") // Name without extension | 28 | + | v.SetConfigName("config") // Name without extension |
| 29 | - | v.SetConfigType("toml") // TOML as default format | 29 | + | v.SetConfigType("toml") // TOML as default format |
| 30 | 30 | ||||
| 31 | // Add config paths | 31 | // 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 development | 37 | v.AddConfigPath(".") // Current directory for development | ||
| 38 | 38 | ||||
| 39 | // Set up environment variable support | 39 | // 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 ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 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 | 14 | "github.com/bnema/dumber/internal/ui/window" | |
| 12 | 15 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 13 | 16 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -29,6 +32,14 @@ type App struct { | |||
| 29 | 32 | tabs *entity.TabList | |
| 30 | 33 | 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 | 43 | // ID generator for tabs/panes | |
| 33 | 44 | idCounter uint64 | |
| 34 | 45 | idMu sync.Mutex | |
| @@ -49,12 +60,14 @@ func New(deps *Dependencies) (*App, error) { | |||
| 49 | 60 | logger := logging.FromContext(ctx) | |
| 50 | 61 | ||
| 51 | 62 | 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, | |
| 58 | 71 | } | |
| 59 | 72 | ||
| 60 | 73 | return app, nil | |
| @@ -102,6 +115,19 @@ func (a *App) onActivate() { | |||
| 102 | 115 | 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 | 131 | // Create an initial tab | |
| 106 | 132 | a.createInitialTab() | |
| 107 | 133 | ||
| @@ -149,6 +175,9 @@ func (a *App) createInitialTab() { | |||
| 149 | 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 | 181 | a.logger.Debug(). | |
| 153 | 182 | Str("tab_id", string(output.Tab.ID)). | |
| 154 | 183 | Msg("initial tab created") | |
| @@ -190,3 +219,351 @@ func RunWithArgs(deps *Dependencies) int { | |||
| 190 | 219 | } | |
| 191 | 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 | + | } | |
| @@ -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.TabList | 32 | tabs *entity.TabList | ||
| 30 | tabsUC *usecase.ManageTabsUseCase | 33 | 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/panes | 43 | // ID generator for tabs/panes | ||
| 33 | idCounter uint64 | 44 | idCounter uint64 | ||
| 34 | idMu sync.Mutex | 45 | 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, nil | 73 | return app, nil | ||
| @@ -102,6 +115,19 @@ func (a *App) onActivate() { | |||||
| 102 | return | 115 | 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 tab | 131 | // 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 | + | } | |||