v0.29.0 21b3150

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/repository"
17	"github.com/bnema/dumber/internal/infrastructure/config"
18	"github.com/bnema/dumber/internal/infrastructure/favicon"
19	infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
20	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
21	"github.com/bnema/dumber/internal/logging"
22)
23
24// App holds CLI dependencies.
25type App struct {
26	Config    *config.Config
27	Theme     *styles.Theme
28	BuildInfo build.Info
29	db        *sql.DB
30	History   repository.HistoryRepository
31
32	// Use cases
33	SearchHistoryUC *usecase.SearchHistoryUseCase
34	SessionUC       *usecase.ManageSessionUseCase
35	ListSessionsUC  *usecase.ListSessionsUseCase
36	RestoreUC       *usecase.RestoreSessionUseCase
37	DeleteSessionUC *usecase.DeleteSessionUseCase
38
39	// Services
40	FaviconService *favicon.Service
41	SessionSpawner port.SessionSpawner
42
43	// Context with logger
44	ctx        context.Context
45	logCleanup func()
46}
47
48// NewApp creates a new CLI application with all dependencies.
49func NewApp() (*App, error) {
50	const dataDirPerm = 0o755
51	var err error
52	// Load config
53	cfg := loadConfig()
54	profile, err := bootstrap.ResolveRuntimeProfile(cfg)
55	if err != nil {
56		return nil, fmt.Errorf("resolve runtime profile: %w", err)
57	}
58
59	// Create theme from config
60	theme := styles.NewTheme(cfg)
61
62	// Start with a quiet logger. If a browser session is active, we'll attach to it.
63	logLevel := cfg.Logging.Level
64	if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
65		logLevel = envLevel
66	}
67
68	logger, logCleanup, _ := logging.NewWithFile(
69		logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
70		logging.FileConfig{Enabled: false, WriteToStderr: false},
71	)
72	ctx := logging.WithContext(context.Background(), logger)
73
74	// Determine database path - use share directory per XDG spec (user data)
75	home, _ := os.UserHomeDir()
76	dbFile := cfg.Database.Path
77	if dbFile == "" {
78		dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
79	}
80	dbPath := filepath.Dir(dbFile)
81
82	// Ensure data directory exists
83	if err = os.MkdirAll(dbPath, dataDirPerm); err != nil {
84		return nil, fmt.Errorf("create data dir: %w", err)
85	}
86
87	// Open database connection
88	db, err := sqlite.NewConnection(ctx, dbFile)
89	if err != nil {
90		return nil, fmt.Errorf("open database: %w", err)
91	}
92
93	// Attach CLI logs to the active browser session (if any).
94	// This avoids creating extra tiny session log files for each CLI invocation.
95	sessionRepo := sqlite.NewSessionRepository(db)
96	sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
97	sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
98	if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
99		attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
100			Level:         logLevel,
101			Format:        cfg.Logging.Format,
102			TimeFormat:    "15:04:05",
103			LogDir:        cfg.Logging.LogDir,
104			WriteToStderr: false,
105			EnableFileLog: cfg.Logging.EnableFileLog,
106		})
107		logger = attachedLogger
108		logCleanup = attachedCleanup
109		ctx = logging.WithContext(context.Background(), logger)
110		logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
111	}
112
113	logger.Debug().Str("db_path", dbFile).Msg("database connected")
114
115	// Create repositories
116	historyRepo := sqlite.NewHistoryRepository(db)
117	sessionStateRepo := sqlite.NewSessionStateRepository(db)
118
119	// Create use cases
120	searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
121	listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
122	restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
123	deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
124
125	// Create favicon service for CLI (path resolution for dmenu/fuzzel)
126	faviconCacheDir, _ := config.GetFaviconCacheDir()
127	faviconService := favicon.NewService(faviconCacheDir)
128
129	return &App{
130		Config:          cfg,
131		Theme:           theme,
132		db:              db,
133		History:         historyRepo,
134		SearchHistoryUC: searchHistoryUC,
135		SessionUC:       sessionUC,
136		ListSessionsUC:  listSessionsUC,
137		RestoreUC:       restoreUC,
138		DeleteSessionUC: deleteSessionUC,
139		FaviconService:  faviconService,
140		SessionSpawner:  bootstrap.NewSessionSpawner(ctx, profile),
141		ctx:             ctx,
142		logCleanup:      logCleanup,
143	}, nil
144}
145
146// Close releases all resources.
147func (a *App) Close() error {
148	if a.logCleanup != nil {
149		a.logCleanup()
150	}
151	if a.FaviconService != nil {
152		a.FaviconService.Close()
153	}
154	if a.db != nil {
155		return a.db.Close()
156	}
157	return nil
158}
159
160// Ctx returns the application context with logger.
161func (a *App) Ctx() context.Context {
162	return a.ctx
163}
164
165// loadConfig loads configuration from standard locations.
166func loadConfig() *config.Config {
167	mgr, err := config.NewManager()
168	if err != nil {
169		// Return default config if manager fails
170		return config.DefaultConfig()
171	}
172
173	if err := mgr.Load(); err != nil {
174		// Return default config if loading fails
175		return config.DefaultConfig()
176	}
177
178	return mgr.Get()
179}

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