Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ packages:
EntryInputTarget: {}
HomepageFavorites: {}
HomepageHistory: {}
HistorySidebarHistory: {}
AllKeybindingsResetter: {}
KeybindingResetter: {}
KeybindingSetter: {}
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ These work outside modal modes:
| Action | Keys |
|--------|------|
| Toggle floating pane | `Alt+F` |
| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |
| Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
| Toggle Favorites system view in right split | unbound by default |
| Toggle Config system view in right split | unbound by default |
| Close pane (or release floating pane) | `Ctrl+W` |
Expand All @@ -96,7 +96,7 @@ These work outside modal modes:

- `Alt+F` is the only floating-pane shortcut enabled by default.
- `Alt+F` toggles floating visibility and keeps floating pane state intact.
- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.
- `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.
- `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
- Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
- Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
Expand Down
16 changes: 16 additions & 0 deletions internal/application/port/history_sidebar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package port

import (
"context"

"github.com/bnema/dumber/internal/application/dto"
"github.com/bnema/dumber/internal/domain/entity"
)

// HistorySidebarHistory provides the narrow history operations needed by the
// native GTK history sidebar.
type HistorySidebarHistory interface {
GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
Delete(ctx context.Context, id int64) error
}
4 changes: 3 additions & 1 deletion internal/application/usecase/search_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import (
"github.com/bnema/dumber/internal/logging"
)

// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
// Compile-time checks: SearchHistoryUseCase must satisfy the application
// history ports used by WebUI handlers and the native GTK history sidebar.
var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)

const (
historyWindowDuration = 24 * time.Hour
Expand Down
8 changes: 5 additions & 3 deletions internal/infrastructure/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const (
defaultMaxLogFiles = 100 // session log files

// Appearance defaults
defaultFontSize = 16 // points
defaultSidebarWidth = 320 // px, clamped to [280, 380]
defaultFontSize = 16 // points
defaultExternalThemeProvider = "noctalia"
defaultExternalThemeFormat = "colors-json"
defaultExternalThemeColorsFilename = "colors.json"
Expand Down Expand Up @@ -251,8 +252,9 @@ func DefaultConfig() *Config {
GLRenderingMode: GLRenderingModeAuto,
},
},
DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
SidebarWidth: defaultSidebarWidth, // 320px, clamped to [280, 380]
Workspace: WorkspaceConfig{
NewPaneURL: defaultNewPaneURL,
SwitchToTabOnMove: true,
Expand Down
1 change: 1 addition & 0 deletions internal/infrastructure/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
}

func (m *Manager) setWorkspaceDefaults(defaults *Config) {
Expand Down
8 changes: 4 additions & 4 deletions internal/infrastructure/config/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,10 @@ const (

// Config format constants.
const (
configFormatTOML = "toml"
configFormatYAML = "yaml"
configFormatJSON = "json"
databasePathKey = "database.path"
configFormatTOML = "toml"
configFormatYAML = "yaml"
configFormatJSON = "json"
databasePathKey = "database.path"
)

type defaultActionMap struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/infrastructure/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ type Config struct {
DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"`
// DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%)
DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
// SidebarWidth sets the preferred width (px) for the history sidebar.
// Clamped to [280, 380] at runtime. 0 means use default (320).
SidebarWidth int `mapstructure:"sidebar_width" yaml:"sidebar_width" toml:"sidebar_width"`
// Workspace defines workspace, pane, and tab handling behavior.
Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
// Session controls session persistence and restoration.
Expand Down
8 changes: 8 additions & 0 deletions internal/infrastructure/config/schema_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInf
Range: "0.5-3.0",
Section: SectionAppearance,
},
{
Key: "sidebar_width",
Type: "int",
Default: fmt.Sprintf("%d", defaults.SidebarWidth),
Description: "Preferred width (px) for the history sidebar. 0 = default",
Range: "0 or 280-380",
Section: SectionAppearance,
},
{
Key: "appearance.light_palette.*",
Type: "string",
Expand Down
3 changes: 3 additions & 0 deletions internal/infrastructure/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ func validateAppearance(config *Config) []string {
if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
}
if config.SidebarWidth != 0 && (config.SidebarWidth < 280 || config.SidebarWidth > 380) {
validationErrors = append(validationErrors, "sidebar_width must be between 280 and 380, or 0 for default")
}
validationErrors = append(validationErrors, validateExternalTheme(config)...)
return validationErrors
}
Expand Down
18 changes: 18 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2222,6 +2222,20 @@ func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.
return a.workspaceViews[tab.ID]
}

// hideAndRestoreFocusForBrowserWindow hides the sidebar and restores focus to
// the active content pane/webview of the given browser window.
func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
if bw == nil {
return
}
bw.hideHistorySidebar()
if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
wsView.FocusPane(ws.ActivePaneID)
}
}
}

// tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
// It does not allocate a TabList; callers that mutate tabs should use
// ensureTabTargetForBrowserWindow instead.
Expand Down Expand Up @@ -3508,6 +3522,7 @@ func (a *App) wireKeyboardActions() {
}
return a.EjectActivePaneToWindow(ctx, paneID)
})
a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
return a.ToggleFloatingPane(ctx)
})
Expand Down Expand Up @@ -5037,6 +5052,9 @@ func (a *App) initConfigWatcher(ctx context.Context) {
if bw == nil {
continue
}
// Reapply sidebar width from live config (reloads after sidebar_width
// changes in the config file).
bw.applySidebarWidthConfig(a)
if bw.keyboardHandler != nil {
bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
}
Expand Down
18 changes: 18 additions & 0 deletions internal/ui/browser_window.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type browserWindow struct {
globalShortcutHandler *input.GlobalShortcutHandler
permissionDialog port.PermissionDialogPresenter
webrtcIndicator *component.WebRTCPermissionIndicator
historySidebar *component.HistorySidebar
sidebarVisible bool
}

func (bw *browserWindow) detachInputForDestroy() {
Expand All @@ -63,6 +65,12 @@ func (bw *browserWindow) clearShellState() {
if bw == nil {
return
}
// Destroy the history sidebar before releasing the reference so its
// context, debounce timer, callbacks, and in-flight goroutines are
// cleaned up before the window itself is torn down.
if bw.historySidebar != nil {
bw.historySidebar.Destroy()
}
bw.appToaster = nil
bw.modeToaster = nil
bw.borderMgr = nil
Expand All @@ -76,6 +84,7 @@ func (bw *browserWindow) clearShellState() {
bw.globalShortcutHandler = nil
bw.permissionDialog = nil
bw.webrtcIndicator = nil
bw.historySidebar = nil
}

func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
Expand All @@ -88,6 +97,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
bw.initAccentPicker(ctx, a)
bw.initSessionManager(ctx, a)
bw.initTabPicker(ctx, a)
bw.initHistorySidebar(ctx, a)
}

func (bw *browserWindow) initToasterOverlay(a *App) {
Expand Down Expand Up @@ -259,6 +269,14 @@ func (bw *browserWindow) ensureTabs() {
}
}

func (a *App) hasBrowserWindow(target *browserWindow) bool {
if a == nil || target == nil || a.browserWindows == nil {
return false
}
bw, ok := a.browserWindows[target.id]
return ok && bw == target
}

func (a *App) registerBrowserWindow(bw *browserWindow) {
if bw == nil {
return
Expand Down
152 changes: 152 additions & 0 deletions internal/ui/browser_window_history_sidebar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package ui

import (
"context"
"fmt"

"github.com/bnema/dumber/internal/application/port"
"github.com/bnema/dumber/internal/application/usecase"
"github.com/bnema/dumber/internal/logging"
"github.com/bnema/dumber/internal/ui/component"
"github.com/bnema/dumber/internal/ui/window"
)

// initHistorySidebar creates and mounts the history sidebar into the
// browser window's sidebar container. The sidebar is hidden by default.
func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.HistoryUC == nil {
return
}
log := logging.FromContext(ctx)

cfg := a.buildHistorySidebarConfig(bw)

sidebar := component.NewHistorySidebar(ctx, cfg)
if sidebar == nil {
log.Warn().Msg("failed to create history sidebar")
return
}

bw.historySidebar = sidebar

// Mount into the main window's sidebar box.
bw.mainWindow.SetSidebarWidget(sidebar.Widget())
bw.historySidebar.Hide()
bw.mainWindow.SetSidebarVisible(false)
bw.sidebarVisible = false

// Apply sidebar width from config, falling back to the default 320px.
// The width is clamped to [280, 380] by SetSidebarWidth internally.
bw.applySidebarWidthConfig(a)

log.Debug().Msg("history sidebar initialized")
}

// buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
// browser window. Extracted from initHistorySidebar for testability.
func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig {
var historyUC port.HistorySidebarHistory
if a.deps != nil {
historyUC = a.deps.HistoryUC
}

return component.HistorySidebarConfig{
HistoryUC: historyUC,
OnNavigate: func(navCtx context.Context, url string) error {
return a.navigateHistorySidebarSelection(navCtx, bw, url)
},
OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
return a.navigateHistorySidebarSelection(navCtx, bw, url)
},
OnOpenInNewPane: func(splitCtx context.Context, url string) error {
if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
return nil
}
a.activateBrowserWindow(bw)
return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
},
OnClose: func() {
a.hideAndRestoreFocusForBrowserWindow(bw)
},
}
}

func (a *App) navigateHistorySidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
return nil
}
return a.navigateFromBrowserWindow(ctx, bw, url)
}

// toggleHistorySidebar toggles sidebar visibility.
func (bw *browserWindow) toggleHistorySidebar() {
if bw == nil || bw.historySidebar == nil {
return
}

if bw.sidebarVisible {
bw.hideHistorySidebar()
} else {
bw.showHistorySidebar()
}
}

// showHistorySidebar makes the sidebar visible and grabs focus for the search
// entry.
func (bw *browserWindow) showHistorySidebar() {
if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
return
}
bw.historySidebar.Show()
bw.mainWindow.SetSidebarVisible(true)
bw.sidebarVisible = true
}

// hideHistorySidebar hides the sidebar. Callers should also restore focus
// to the active content pane after calling this.
func (bw *browserWindow) hideHistorySidebar() {
if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
return
}
bw.historySidebar.Hide()
bw.mainWindow.SetSidebarVisible(false)
bw.sidebarVisible = false
}

// historySidebarWidthConfig extracts the config-backed sidebar width and
// applies it via the MainWindow.SetSidebarWidth path. It is called during
// initialization and can be reused if config is reloaded at runtime.
func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
cfg := window.SidebarDefaultWidth()
if widthPx > 0 {
cfg.WidthPx = widthPx
}
return cfg
}

// applySidebarWidthConfig applies the config-backed sidebar width to the
// main window's sidebar.
func (bw *browserWindow) applySidebarWidthConfig(a *App) {
if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
return
}
bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
}

// toggleHistorySidebarAction is the keyboard-action handler for toggling the
// history sidebar on the last focused browser window.
func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
bw := a.lastFocusedBrowserWindow()
if bw == nil {
return fmt.Errorf("history sidebar unavailable: no focused browser window")
}
if bw.historySidebar == nil {
return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
}
if bw.sidebarVisible {
a.hideAndRestoreFocusForBrowserWindow(bw)
return nil
}
bw.toggleHistorySidebar()
return nil
}
Loading