v0.33.2 57f21d6

app.go RawHistoryFolder

1// Package cli provides CLI commands using Bubble Tea TUI.
2package cli
3
4import (
5	"context"
6	"database/sql"
7	"fmt"
8	"os"
9	"path/filepath"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/bootstrap"
14	"github.com/bnema/dumber/internal/cli/styles"
15	"github.com/bnema/dumber/internal/domain/build"
16	"github.com/bnema/dumber/internal/domain/entity"
17	"github.com/bnema/dumber/internal/domain/repository"
18	"github.com/bnema/dumber/internal/infrastructure/config"
19	"github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
20	"github.com/bnema/dumber/internal/infrastructure/favicon"
21	"github.com/bnema/dumber/internal/infrastructure/filesystem"
22	infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
23	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
24	"github.com/bnema/dumber/internal/logging"
25)
26
27// App holds CLI dependencies.
28type App struct {
29	Config    *config.Config
30	Theme     *styles.Theme
31	BuildInfo build.Info
32	db        *sql.DB
33	History   repository.HistoryRepository
34
35	// Use cases
36	SearchHistoryUC *usecase.SearchHistoryUseCase
37	SessionUC       *usecase.ManageSessionUseCase
38	ListSessionsUC  *usecase.ListSessionsUseCase
39	RestoreUC       *usecase.RestoreSessionUseCase
40	DeleteSessionUC *usecase.DeleteSessionUseCase
41
42	// Services
43	FaviconService          *favicon.Service
44	SessionSpawner          port.SessionSpawner
45	LocalPaths              port.LocalPathResolver
46	NavigationURLNormalizer *usecase.NavigationURLNormalizer
47
48	// Context with logger
49	ctx        context.Context
50	logCleanup func()
51}
52
53// NewApp creates a new CLI application with all dependencies.
54func NewApp() (*App, error) {
55	const dataDirPerm = 0o755
56	var err error
57	// Load config
58	cfg := loadConfig()
59	profile, err := bootstrap.ResolveRuntimeProfile(cfg)
60	if err != nil {
61		return nil, fmt.Errorf("resolve runtime profile: %w", err)
62	}
63
64	// Create theme through the shared resolver. CLI keeps the historical dark default
65	// unless config explicitly requests a light scheme.
66	theme := styles.NewThemeFromResolved(resolveCLITheme(cfg))
67
68	// Start with a quiet logger. If a browser session is active, we'll attach to it.
69	logLevel := cfg.Logging.Level
70	if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
71		logLevel = envLevel
72	}
73
74	logger, logCleanup, _ := logging.NewWithFile(
75		logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
76		logging.FileConfig{Enabled: false, WriteToStderr: false},
77	)
78	ctx := logging.WithContext(context.Background(), logger)
79
80	// Determine database path - use share directory per XDG spec (user data)
81	home, _ := os.UserHomeDir()
82	dbFile := cfg.Database.Path
83	if dbFile == "" {
84		dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
85	}
86	dbPath := filepath.Dir(dbFile)
87
88	// Ensure data directory exists
89	if err = os.MkdirAll(dbPath, dataDirPerm); err != nil {
90		return nil, fmt.Errorf("create data dir: %w", err)
91	}
92
93	// Open database connection
94	db, err := sqlite.NewConnection(ctx, dbFile)
95	if err != nil {
96		return nil, fmt.Errorf("open database: %w", err)
97	}
98
99	// Attach CLI logs to the active browser session (if any).
100	// This avoids creating extra tiny session log files for each CLI invocation.
101	sessionRepo := sqlite.NewSessionRepository(db)
102	sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
103	sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
104	if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
105		attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
106			Level:         logLevel,
107			Format:        cfg.Logging.Format,
108			TimeFormat:    "15:04:05",
109			LogDir:        cfg.Logging.LogDir,
110			WriteToStderr: false,
111			EnableFileLog: cfg.Logging.EnableFileLog,
112		})
113		logger = attachedLogger
114		logCleanup = attachedCleanup
115		ctx = logging.WithContext(context.Background(), logger)
116		logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
117	}
118
119	logger.Debug().Str("db_path", dbFile).Msg("database connected")
120
121	// Create repositories
122	historyRepo := sqlite.NewHistoryRepository(db)
123	sessionStateRepo := sqlite.NewSessionStateRepository(db)
124
125	// Create use cases
126	searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
127	listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
128	restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
129	deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
130
131	// Create favicon service for CLI (path resolution for dmenu/fuzzel)
132	faviconCacheDir, _ := config.GetFaviconCacheDir()
133	faviconService := favicon.NewService(faviconCacheDir)
134	localPaths := filesystem.New()
135
136	return &App{
137		Config:                  cfg,
138		Theme:                   theme,
139		db:                      db,
140		History:                 historyRepo,
141		SearchHistoryUC:         searchHistoryUC,
142		SessionUC:               sessionUC,
143		ListSessionsUC:          listSessionsUC,
144		RestoreUC:               restoreUC,
145		DeleteSessionUC:         deleteSessionUC,
146		FaviconService:          faviconService,
147		SessionSpawner:          bootstrap.NewSessionSpawner(ctx, profile),
148		LocalPaths:              localPaths,
149		NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150		ctx:                     ctx,
151		logCleanup:              logCleanup,
152	}, nil
153}
154
155// Close releases all resources.
156func (a *App) Close() error {
157	if a.logCleanup != nil {
158		a.logCleanup()
159	}
160	if a.FaviconService != nil {
161		a.FaviconService.Close()
162	}
163	if a.db != nil {
164		return a.db.Close()
165	}
166	return nil
167}
168
169func resolveCLITheme(cfg *config.Config) entity.ResolvedTheme {
170	colorScheme := ""
171	if cfg != nil {
172		colorScheme = cfg.Appearance.ColorScheme
173	}
174	preference := usecase.ColorSchemePreferenceFromConfig(colorScheme, port.ColorSchemePreference{
175		PrefersDark: true,
176		Source:      "cli",
177	})
178
179	var appearance *entity.AppearanceConfig
180	var styling *entity.WorkspaceStylingConfig
181	var uiScale float64
182	if cfg != nil {
183		appearance = &cfg.Appearance
184		styling = &cfg.Workspace.Styling
185		uiScale = cfg.DefaultUIScale
186	}
187
188	externalThemeSource := noctalia.NewFileSource(false, "")
189	if cfg != nil {
190		externalThemeSource.Configure(cfg.Appearance.ExternalTheme)
191	}
192	uc := usecase.NewResolveThemeUseCase(externalThemeSource)
193	out, err := uc.Execute(context.Background(), usecase.ResolveThemeInputFromConfig(
194		appearance,
195		uiScale,
196		styling,
197		preference,
198	))
199	if err != nil {
200		return entity.ResolvedTheme{ActivePalette: entity.DefaultDarkPalette()}
201	}
202	return out.Theme
203}
204
205// Ctx returns the application context with logger.
206func (a *App) Ctx() context.Context {
207	return a.ctx
208}
209
210// loadConfig loads configuration from standard locations.
211func loadConfig() *config.Config {
212	mgr, err := config.NewManager()
213	if err != nil {
214		// Return default config if manager fails
215		return config.DefaultConfig()
216	}
217
218	if err := mgr.Load(); err != nil {
219		// Return default config if loading fails
220		return config.DefaultConfig()
221	}
222
223	return mgr.Get()
224}

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