Skip to content

Commit 3b65f5e

Browse files
feat(ui): add rain session logs and status panel (#14)
Preserve rain selector-to-CLI flow while adding structured session logging, an in-TUI status/log surface, and text export so operators and agents get consistent audit trails without forcing TUI-only execution. Made-with: Cursor
1 parent 692d726 commit 3b65f5e

8 files changed

Lines changed: 395 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity.
367367

368368
`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel.
369369

370+
Status strip and optional log panel are driven from structured session events. Confirming selection still exits TUI and runs fetch/sync work in normal CLI output.
371+
370372
**Key bindings:**
371373

372374
| Key | Action |
@@ -377,6 +379,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity.
377379
| `q` / `ctrl+c` | Abort picker |
378380
| `c` / `Esc` | Back from settings (ignored list uses `Esc` / `i` / `b`) |
379381
| `` / `` | Navigate |
382+
| `Shift+L` | Toggle in-TUI log panel |
383+
| `e` | Export visible TUI log buffer to `~/.cache/git-rain/exports/` |
380384

381385
## Safe Mode vs Risky Mode
382386

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/git-rain/git-rain/internal/git"
2424
"github.com/git-rain/git-rain/internal/registry"
2525
"github.com/git-rain/git-rain/internal/safety"
26+
"github.com/git-rain/git-rain/internal/sessionlog"
2627
"github.com/git-rain/git-rain/internal/ui"
2728
)
2829

@@ -508,6 +509,11 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
508509

509510
userCfgDir, _ := config.UserGitRainDir()
510511
cfgPath := filepath.Join(userCfgDir, "config.toml")
512+
logger, err := sessionlog.NewLogger(sessionlog.DefaultLogDir())
513+
if err != nil {
514+
return fmt.Errorf("init rain logger: %w", err)
515+
}
516+
defer func() { _ = logger.Close() }()
511517

512518
selected, err := ui.RunRepoSelectorStream(
513519
tuiRepoChan,
@@ -518,6 +524,7 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
518524
cfgPath,
519525
reg,
520526
regPath,
527+
logger,
521528
)
522529

523530
// Cancel scan first so filepath walk and in-flight git subprocesses unwind.

internal/sessionlog/logger.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package sessionlog
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
"github.com/git-rain/git-rain/internal/safety"
11+
)
12+
13+
// LogEntry matches git-fire's structured session event shape.
14+
type LogEntry struct {
15+
Timestamp time.Time `json:"timestamp"`
16+
Level string `json:"level"`
17+
Repo string `json:"repo,omitempty"`
18+
Action string `json:"action"`
19+
Description string `json:"description"`
20+
Error string `json:"error,omitempty"`
21+
Duration string `json:"duration,omitempty"`
22+
}
23+
24+
// EventSubscriber receives structured entries as they are written.
25+
type EventSubscriber func(LogEntry)
26+
27+
// Logger writes JSONL session logs.
28+
type Logger struct {
29+
logPath string
30+
file *os.File
31+
writes int
32+
subscribers []EventSubscriber
33+
}
34+
35+
// NewLogger creates a new rain session logger.
36+
func NewLogger(logDir string) (*Logger, error) {
37+
if err := os.MkdirAll(logDir, 0o700); err != nil {
38+
return nil, fmt.Errorf("create log dir: %w", err)
39+
}
40+
logFilename := fmt.Sprintf("git-rain-%s.log", time.Now().Format("20060102-150405"))
41+
logPath := filepath.Join(logDir, logFilename)
42+
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
43+
if err != nil {
44+
return nil, fmt.Errorf("create log file: %w", err)
45+
}
46+
logger := &Logger{logPath: logPath, file: file}
47+
logger.Info("", "git-rain-start", "Git-rain session started")
48+
return logger, nil
49+
}
50+
51+
// Subscribe registers a process-local subscriber.
52+
func (l *Logger) Subscribe(fn EventSubscriber) {
53+
if fn == nil {
54+
return
55+
}
56+
l.subscribers = append(l.subscribers, fn)
57+
}
58+
59+
// Log writes one JSONL entry.
60+
func (l *Logger) Log(entry LogEntry) error {
61+
if l.file == nil {
62+
return fmt.Errorf("logger not initialized")
63+
}
64+
entry.Timestamp = time.Now()
65+
data, err := json.Marshal(entry)
66+
if err != nil {
67+
return fmt.Errorf("marshal log entry: %w", err)
68+
}
69+
if _, err := l.file.Write(append(data, '\n')); err != nil {
70+
return fmt.Errorf("write log entry: %w", err)
71+
}
72+
for _, fn := range l.subscribers {
73+
fn(entry)
74+
}
75+
l.writes++
76+
if l.writes%20 == 0 {
77+
return l.file.Sync()
78+
}
79+
return nil
80+
}
81+
82+
// Info writes a level=info entry.
83+
func (l *Logger) Info(repo, action, description string) {
84+
_ = l.Log(LogEntry{Level: "info", Repo: repo, Action: action, Description: description})
85+
}
86+
87+
// Error writes a level=error entry with redaction.
88+
func (l *Logger) Error(repo, action, description string, err error) {
89+
e := LogEntry{Level: "error", Repo: repo, Action: action, Description: description}
90+
if err != nil {
91+
e.Error = safety.SanitizeText(err.Error())
92+
}
93+
_ = l.Log(e)
94+
}
95+
96+
// Success writes a level=success entry.
97+
func (l *Logger) Success(repo, action, description string, duration time.Duration) {
98+
_ = l.Log(LogEntry{
99+
Level: "success",
100+
Repo: repo,
101+
Action: action,
102+
Description: description,
103+
Duration: duration.String(),
104+
})
105+
}
106+
107+
// Close writes end marker and closes file.
108+
func (l *Logger) Close() error {
109+
if l.file == nil {
110+
return nil
111+
}
112+
l.Info("", "git-rain-end", "Git-rain session ended")
113+
_ = l.file.Sync()
114+
return l.file.Close()
115+
}
116+
117+
// LogPath returns current file path.
118+
func (l *Logger) LogPath() string { return l.logPath }
119+
120+
// DefaultLogDir resolves standard cache path for rain logs.
121+
func DefaultLogDir() string {
122+
base, err := os.UserCacheDir()
123+
if err != nil {
124+
return filepath.Join(os.TempDir(), "git-rain", "logs")
125+
}
126+
return filepath.Join(base, "git-rain", "logs")
127+
}

internal/sessionlog/logger_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package sessionlog
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestDefaultLogDir(t *testing.T) {
13+
dir := DefaultLogDir()
14+
if dir == "" {
15+
t.Fatal("DefaultLogDir() should not be empty")
16+
}
17+
if !strings.Contains(dir, filepath.Join("git-rain", "logs")) {
18+
t.Fatalf("unexpected DefaultLogDir: %s", dir)
19+
}
20+
}
21+
22+
func TestLogger_WritesJSONL(t *testing.T) {
23+
logger, err := NewLogger(t.TempDir())
24+
if err != nil {
25+
t.Fatalf("NewLogger() error = %v", err)
26+
}
27+
logger.Info("repo", "scan", "repo discovered")
28+
logger.Success("repo", "scan-complete", "done", time.Second)
29+
if err := logger.Close(); err != nil {
30+
t.Fatalf("Close() error = %v", err)
31+
}
32+
33+
data, err := os.ReadFile(logger.LogPath())
34+
if err != nil {
35+
t.Fatalf("ReadFile() error = %v", err)
36+
}
37+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
38+
if len(lines) < 3 {
39+
t.Fatalf("expected at least 3 log lines, got %d", len(lines))
40+
}
41+
for _, line := range lines {
42+
var entry LogEntry
43+
if err := json.Unmarshal([]byte(line), &entry); err != nil {
44+
t.Fatalf("invalid JSON line %q: %v", line, err)
45+
}
46+
}
47+
}
48+
49+
func TestLogger_Subscribe(t *testing.T) {
50+
logger, err := NewLogger(t.TempDir())
51+
if err != nil {
52+
t.Fatalf("NewLogger() error = %v", err)
53+
}
54+
defer func() { _ = logger.Close() }()
55+
56+
seen := make(chan LogEntry, 1)
57+
logger.Subscribe(func(e LogEntry) { seen <- e })
58+
logger.Info("repo", "scan", "repo discovered")
59+
60+
select {
61+
case entry := <-seen:
62+
if entry.Action != "scan" {
63+
t.Fatalf("entry.Action = %q, want scan", entry.Action)
64+
}
65+
case <-time.After(2 * time.Second):
66+
t.Fatal("did not receive subscribed log event")
67+
}
68+
}

internal/ui/repo_selector.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/git-rain/git-rain/internal/config"
1515
"github.com/git-rain/git-rain/internal/git"
1616
"github.com/git-rain/git-rain/internal/registry"
17+
"github.com/git-rain/git-rain/internal/sessionlog"
1718
)
1819

1920
// ErrCancelled is returned by RunRepoSelector when the user cancels the TUI.
@@ -171,6 +172,13 @@ type RepoSelectorModel struct {
171172
cfgPath string
172173
configCursor int
173174
configSaveErr error
175+
176+
logger *sessionlog.Logger
177+
showLogPanel bool
178+
statusLine string
179+
statusIcon string
180+
logEntries []sessionlog.LogEntry
181+
logExportPath string
174182
}
175183

176184
// NewRepoSelectorModel creates a new repo selector model (static mode).
@@ -208,6 +216,8 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat
208216
currentStartupQuote: randomStartupRainQuote(),
209217
startupQuoteVisible: true,
210218
quoteTickActive: true,
219+
statusLine: "selector ready",
220+
statusIcon: "ℹ️",
211221
}
212222
}
213223

@@ -286,6 +296,8 @@ func NewRepoSelectorModelStream(
286296
currentStartupQuote: randomStartupRainQuote(),
287297
startupQuoteVisible: showStartupQuote,
288298
quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0,
299+
statusLine: "scan starting",
300+
statusIcon: "🔍",
289301
}
290302
}
291303

@@ -303,6 +315,24 @@ func (m RepoSelectorModel) Init() tea.Cmd {
303315
return tea.Batch(cmds...)
304316
}
305317

318+
func (m *RepoSelectorModel) recordStatus(level, action, description string) {
319+
entry := sessionlog.LogEntry{
320+
Timestamp: time.Now(),
321+
Level: level,
322+
Action: action,
323+
Description: description,
324+
}
325+
m.statusIcon = statusGlyph(entry)
326+
m.statusLine = description
327+
m.logEntries = append(m.logEntries, entry)
328+
if len(m.logEntries) > 200 {
329+
m.logEntries = m.logEntries[len(m.logEntries)-200:]
330+
}
331+
if m.logger != nil {
332+
_ = m.logger.Log(entry)
333+
}
334+
}
335+
306336
func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307337
var cmds []tea.Cmd
308338

@@ -321,15 +351,18 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321351
if m.scanChan != nil {
322352
cmds = append(cmds, waitForRepo(m.scanChan))
323353
}
354+
m.recordStatus("info", "scan-repo", fmt.Sprintf("discovered %s", repo.Name))
324355

325356
case scanProgressMsg:
326357
m.scanCurrentPath = string(msg)
327358
if m.progressChan != nil && !m.progDone {
328359
cmds = append(cmds, waitForProgress(m.progressChan))
329360
}
361+
m.recordStatus("info", "scan-progress", fmt.Sprintf("scanning %s", m.scanCurrentPath))
330362

331363
case repoChanDoneMsg:
332364
m.scanDone = true
365+
m.recordStatus("success", "scan-complete", "scan complete")
333366

334367
case progressChanDoneMsg:
335368
m.progDone = true
@@ -479,6 +512,23 @@ func (m RepoSelectorModel) updateMainView(msg tea.KeyMsg, cmds []tea.Cmd) (tea.M
479512
m = m.saveConfig()
480513
}
481514

515+
case "L":
516+
m.showLogPanel = !m.showLogPanel
517+
if m.showLogPanel {
518+
m.recordStatus("info", "log-panel-open", "log panel opened")
519+
} else {
520+
m.recordStatus("info", "log-panel-close", "log panel closed")
521+
}
522+
523+
case "e":
524+
path, err := exportLogEntriesText(m.logEntries)
525+
if err != nil {
526+
m.recordStatus("error", "log-export-failed", err.Error())
527+
} else {
528+
m.logExportPath = path
529+
m.recordStatus("success", "log-exported", fmt.Sprintf("exported log to %s", path))
530+
}
531+
482532
case "i":
483533
m.ignoredEntries = IgnoredRegistryEntries(m.reg)
484534
m.ignoredCursor = 0
@@ -1000,8 +1050,10 @@ func RunRepoSelectorStream(
10001050
cfgPath string,
10011051
reg *registry.Registry,
10021052
regPath string,
1053+
logger *sessionlog.Logger,
10031054
) ([]git.Repository, error) {
10041055
model := NewRepoSelectorModelStream(scanChan, progressChan, scanDisabled, scanDisabledRunOnly, cfg, cfgPath, reg, regPath)
1056+
model.logger = logger
10051057
p := tea.NewProgram(model, tea.WithAltScreen())
10061058

10071059
finalModel, err := p.Run()

0 commit comments

Comments
 (0)