detached 4b325e3

feat(logging): add file-based session logging for GUI and CLI

commit
4b325e345800d6dca4e8b07e331c33c5f1dc1cba
author
bnema · · 2025-12-17 20:45 UTC
parent
99cf04e
signature
unsigned
2 files · +111 -4
M cmd/dumber/main.go +48 -4
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/dumber/assets"
1111 "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/cmd"
1213 "github.com/bnema/dumber/internal/infrastructure/config"
1314 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1415 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -24,7 +25,27 @@ var (
2425 buildDate = "unknown"
2526 )
2627
28+// initialURL holds the URL to open on startup (from browse command).
29+var initialURL string
30+
2731 func main() {
32+ // Run GUI mode for browse command
33+ if len(os.Args) > 1 && os.Args[1] == "browse" {
34+ // Extract URL if provided
35+ if len(os.Args) > 2 {
36+ initialURL = os.Args[2]
37+ }
38+ // Strip "browse" and URL from args so GTK doesn't see them
39+ os.Args = os.Args[:1]
40+ runGUI()
41+ return
42+ }
43+
44+ // Default: run CLI (shows help if no subcommand)
45+ cmd.Execute()
46+}
47+
48+func runGUI() {
2849 // GTK requires all GTK calls to be made from the main thread
2950 runtime.LockOSThread()
3051
@@ -36,8 +57,30 @@ func main() {
3657
3758 cfg := config.Get()
3859
39- // Initialize logger from config
40- logger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
60+ // Generate session ID for this browser run
61+ sessionID := logging.GenerateSessionID()
62+
63+ // Initialize logger with session file output
64+ logger, logCleanup, err := logging.NewWithFile(
65+ logging.Config{
66+ Level: logging.ParseLevel(cfg.Logging.Level),
67+ Format: cfg.Logging.Format,
68+ TimeFormat: "15:04:05",
69+ },
70+ logging.FileConfig{
71+ Enabled: cfg.Logging.EnableFileLog,
72+ LogDir: cfg.Logging.LogDir,
73+ SessionID: sessionID,
74+ },
75+ )
76+ if err != nil {
77+ // Fall back to stderr-only logger
78+ logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
79+ fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err)
80+ } else {
81+ defer logCleanup()
82+ }
83+
4184 logger.Info().
4285 Str("version", version).
4386 Str("commit", commit).
@@ -61,8 +104,8 @@ func main() {
61104 logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
62105 }
63106
64- // Initialize SQLite database
65- dbPath := filepath.Join(stateDir, "dumber.db")
107+ // Initialize SQLite database (in data dir per XDG spec - user data)
108+ dbPath := filepath.Join(dataDir, "dumber.db")
66109 db, err := sqlite.NewConnection(ctx, dbPath)
67110 if err != nil {
68111 logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
@@ -113,6 +156,7 @@ func main() {
113156 deps := &ui.Dependencies{
114157 Ctx: ctx,
115158 Config: cfg,
159+ InitialURL: initialURL,
116160 Theme: themeManager,
117161 WebContext: wkCtx,
118162 Pool: pool,
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/dumber/assets"10 "github.com/bnema/dumber/assets"
11 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/cmd"
12 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
13 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"14 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
14 "github.com/bnema/dumber/internal/infrastructure/webkit"15 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -24,7 +25,27 @@ var (
24 buildDate = "unknown"25 buildDate = "unknown"
25 )26 )
26 27
28+// initialURL holds the URL to open on startup (from browse command).
29+var initialURL string
30+
27 func main() {31 func main() {
32+ // Run GUI mode for browse command
33+ if len(os.Args) > 1 && os.Args[1] == "browse" {
34+ // Extract URL if provided
35+ if len(os.Args) > 2 {
36+ initialURL = os.Args[2]
37+ }
38+ // Strip "browse" and URL from args so GTK doesn't see them
39+ os.Args = os.Args[:1]
40+ runGUI()
41+ return
42+ }
43+
44+ // Default: run CLI (shows help if no subcommand)
45+ cmd.Execute()
46+}
47+
48+func runGUI() {
28 // GTK requires all GTK calls to be made from the main thread49 // GTK requires all GTK calls to be made from the main thread
29 runtime.LockOSThread()50 runtime.LockOSThread()
30 51
@@ -36,8 +57,30 @@ func main() {
36 57
37 cfg := config.Get()58 cfg := config.Get()
38 59
39- // Initialize logger from config60+ // Generate session ID for this browser run
40- logger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)61+ sessionID := logging.GenerateSessionID()
62+
63+ // Initialize logger with session file output
64+ logger, logCleanup, err := logging.NewWithFile(
65+ logging.Config{
66+ Level: logging.ParseLevel(cfg.Logging.Level),
67+ Format: cfg.Logging.Format,
68+ TimeFormat: "15:04:05",
69+ },
70+ logging.FileConfig{
71+ Enabled: cfg.Logging.EnableFileLog,
72+ LogDir: cfg.Logging.LogDir,
73+ SessionID: sessionID,
74+ },
75+ )
76+ if err != nil {
77+ // Fall back to stderr-only logger
78+ logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
79+ fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err)
80+ } else {
81+ defer logCleanup()
82+ }
83+
41 logger.Info().84 logger.Info().
42 Str("version", version).85 Str("version", version).
43 Str("commit", commit).86 Str("commit", commit).
@@ -61,8 +104,8 @@ func main() {
61 logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")104 logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
62 }105 }
63 106
64- // Initialize SQLite database107+ // Initialize SQLite database (in data dir per XDG spec - user data)
65- dbPath := filepath.Join(stateDir, "dumber.db")108+ dbPath := filepath.Join(dataDir, "dumber.db")
66 db, err := sqlite.NewConnection(ctx, dbPath)109 db, err := sqlite.NewConnection(ctx, dbPath)
67 if err != nil {110 if err != nil {
68 logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")111 logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
@@ -113,6 +156,7 @@ func main() {
113 deps := &ui.Dependencies{156 deps := &ui.Dependencies{
114 Ctx: ctx,157 Ctx: ctx,
115 Config: cfg,158 Config: cfg,
159+ InitialURL: initialURL,
116 Theme: themeManager,160 Theme: themeManager,
117 WebContext: wkCtx,161 WebContext: wkCtx,
118 Pool: pool,162 Pool: pool,
M internal/logging/logger.go +63 -0
@@ -3,6 +3,7 @@ package logging
33 import (
44 "io"
55 "os"
6+ "path/filepath"
67 "time"
78
89 "github.com/rs/zerolog"
@@ -15,6 +16,13 @@ type Config struct {
1516 TimeFormat string
1617 }
1718
19+// FileConfig holds file logging configuration
20+type FileConfig struct {
21+ Enabled bool
22+ LogDir string
23+ SessionID string
24+}
25+
1826 // DefaultConfig returns sensible defaults
1927 func DefaultConfig() Config {
2028 return Config{
@@ -46,6 +54,61 @@ func New(cfg Config) zerolog.Logger {
4654 Logger()
4755 }
4856
57+// NewWithFile creates a logger that writes to both stderr and a session log file.
58+// The session file uses JSON format for easy parsing by the CLI logs command.
59+// LogDir must exist before calling this function (handled by config.EnsureDirectories).
60+// Returns the logger and a cleanup function to close the file.
61+func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
62+ var writers []io.Writer
63+ var cleanup func() = func() {}
64+
65+ // Console output (stderr)
66+ switch cfg.Format {
67+ case "console", "text", "":
68+ writers = append(writers, zerolog.ConsoleWriter{
69+ Out: os.Stderr,
70+ TimeFormat: cfg.TimeFormat,
71+ })
72+ case "json":
73+ writers = append(writers, os.Stderr)
74+ default:
75+ writers = append(writers, zerolog.ConsoleWriter{
76+ Out: os.Stderr,
77+ TimeFormat: cfg.TimeFormat,
78+ })
79+ }
80+
81+ // Session file output (JSON format for parsing)
82+ if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
83+ // Ensure log directory exists (LogDir is from config, may not have logs subdir)
84+ if err := os.MkdirAll(fileCfg.LogDir, 0755); err != nil {
85+ return zerolog.Logger{}, nil, err
86+ }
87+
88+ filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
89+ file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
90+ if err != nil {
91+ return zerolog.Logger{}, nil, err
92+ }
93+
94+ writers = append(writers, file)
95+ cleanup = func() { file.Close() }
96+ }
97+
98+ multi := zerolog.MultiLevelWriter(writers...)
99+ ctx := zerolog.New(multi).
100+ Level(cfg.Level).
101+ With().
102+ Timestamp()
103+
104+ // Add session ID to all log entries if provided
105+ if fileCfg.SessionID != "" {
106+ ctx = ctx.Str("session", fileCfg.SessionID)
107+ }
108+
109+ return ctx.Logger(), cleanup, nil
110+}
111+
49112 // NewFromEnv creates a logger based on environment variables
50113 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
51114 // DUMBER_LOG_FORMAT: json, console (default: console)
@@ -3,6 +3,7 @@ package logging
3 import (3 import (
4 "io"4 "io"
5 "os"5 "os"
6+ "path/filepath"
6 "time"7 "time"
7 8
8 "github.com/rs/zerolog"9 "github.com/rs/zerolog"
@@ -15,6 +16,13 @@ type Config struct {
15 TimeFormat string16 TimeFormat string
16 }17 }
17 18
19+// FileConfig holds file logging configuration
20+type FileConfig struct {
21+ Enabled bool
22+ LogDir string
23+ SessionID string
24+}
25+
18 // DefaultConfig returns sensible defaults26 // DefaultConfig returns sensible defaults
19 func DefaultConfig() Config {27 func DefaultConfig() Config {
20 return Config{28 return Config{
@@ -46,6 +54,61 @@ func New(cfg Config) zerolog.Logger {
46 Logger()54 Logger()
47 }55 }
48 56
57+// NewWithFile creates a logger that writes to both stderr and a session log file.
58+// The session file uses JSON format for easy parsing by the CLI logs command.
59+// LogDir must exist before calling this function (handled by config.EnsureDirectories).
60+// Returns the logger and a cleanup function to close the file.
61+func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
62+ var writers []io.Writer
63+ var cleanup func() = func() {}
64+
65+ // Console output (stderr)
66+ switch cfg.Format {
67+ case "console", "text", "":
68+ writers = append(writers, zerolog.ConsoleWriter{
69+ Out: os.Stderr,
70+ TimeFormat: cfg.TimeFormat,
71+ })
72+ case "json":
73+ writers = append(writers, os.Stderr)
74+ default:
75+ writers = append(writers, zerolog.ConsoleWriter{
76+ Out: os.Stderr,
77+ TimeFormat: cfg.TimeFormat,
78+ })
79+ }
80+
81+ // Session file output (JSON format for parsing)
82+ if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
83+ // Ensure log directory exists (LogDir is from config, may not have logs subdir)
84+ if err := os.MkdirAll(fileCfg.LogDir, 0755); err != nil {
85+ return zerolog.Logger{}, nil, err
86+ }
87+
88+ filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
89+ file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
90+ if err != nil {
91+ return zerolog.Logger{}, nil, err
92+ }
93+
94+ writers = append(writers, file)
95+ cleanup = func() { file.Close() }
96+ }
97+
98+ multi := zerolog.MultiLevelWriter(writers...)
99+ ctx := zerolog.New(multi).
100+ Level(cfg.Level).
101+ With().
102+ Timestamp()
103+
104+ // Add session ID to all log entries if provided
105+ if fileCfg.SessionID != "" {
106+ ctx = ctx.Str("session", fileCfg.SessionID)
107+ }
108+
109+ return ctx.Logger(), cleanup, nil
110+}
111+
49 // NewFromEnv creates a logger based on environment variables112 // NewFromEnv creates a logger based on environment variables
50 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)113 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
51 // DUMBER_LOG_FORMAT: json, console (default: console)114 // DUMBER_LOG_FORMAT: json, console (default: console)

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