diff --git a/README.md b/README.md index 98bfe48..bbb9cec 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity. `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. +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. + **Key bindings:** | Key | Action | @@ -377,6 +379,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity. | `q` / `ctrl+c` | Abort picker | | `c` / `Esc` | Back from settings (ignored list uses `Esc` / `i` / `b`) | | `↑` / `↓` | Navigate | +| `Shift+L` | Toggle in-TUI log panel | +| `e` | Export visible TUI log buffer to `~/.cache/git-rain/exports/` | ## Safe Mode vs Risky Mode diff --git a/cmd/root.go b/cmd/root.go index e100f01..cb37719 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/git-rain/git-rain/internal/git" "github.com/git-rain/git-rain/internal/registry" "github.com/git-rain/git-rain/internal/safety" + "github.com/git-rain/git-rain/internal/sessionlog" "github.com/git-rain/git-rain/internal/ui" ) @@ -508,6 +509,11 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string userCfgDir, _ := config.UserGitRainDir() cfgPath := filepath.Join(userCfgDir, "config.toml") + logger, err := sessionlog.NewLogger(sessionlog.DefaultLogDir()) + if err != nil { + return fmt.Errorf("init rain logger: %w", err) + } + defer func() { _ = logger.Close() }() selected, err := ui.RunRepoSelectorStream( tuiRepoChan, @@ -518,6 +524,7 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string cfgPath, reg, regPath, + logger, ) // Cancel scan first so filepath walk and in-flight git subprocesses unwind. diff --git a/internal/sessionlog/logger.go b/internal/sessionlog/logger.go new file mode 100644 index 0000000..d016fe6 --- /dev/null +++ b/internal/sessionlog/logger.go @@ -0,0 +1,127 @@ +package sessionlog + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/git-rain/git-rain/internal/safety" +) + +// LogEntry matches git-fire's structured session event shape. +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Repo string `json:"repo,omitempty"` + Action string `json:"action"` + Description string `json:"description"` + Error string `json:"error,omitempty"` + Duration string `json:"duration,omitempty"` +} + +// EventSubscriber receives structured entries as they are written. +type EventSubscriber func(LogEntry) + +// Logger writes JSONL session logs. +type Logger struct { + logPath string + file *os.File + writes int + subscribers []EventSubscriber +} + +// NewLogger creates a new rain session logger. +func NewLogger(logDir string) (*Logger, error) { + if err := os.MkdirAll(logDir, 0o700); err != nil { + return nil, fmt.Errorf("create log dir: %w", err) + } + logFilename := fmt.Sprintf("git-rain-%s.log", time.Now().Format("20060102-150405")) + logPath := filepath.Join(logDir, logFilename) + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("create log file: %w", err) + } + logger := &Logger{logPath: logPath, file: file} + logger.Info("", "git-rain-start", "Git-rain session started") + return logger, nil +} + +// Subscribe registers a process-local subscriber. +func (l *Logger) Subscribe(fn EventSubscriber) { + if fn == nil { + return + } + l.subscribers = append(l.subscribers, fn) +} + +// Log writes one JSONL entry. +func (l *Logger) Log(entry LogEntry) error { + if l.file == nil { + return fmt.Errorf("logger not initialized") + } + entry.Timestamp = time.Now() + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal log entry: %w", err) + } + if _, err := l.file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write log entry: %w", err) + } + for _, fn := range l.subscribers { + fn(entry) + } + l.writes++ + if l.writes%20 == 0 { + return l.file.Sync() + } + return nil +} + +// Info writes a level=info entry. +func (l *Logger) Info(repo, action, description string) { + _ = l.Log(LogEntry{Level: "info", Repo: repo, Action: action, Description: description}) +} + +// Error writes a level=error entry with redaction. +func (l *Logger) Error(repo, action, description string, err error) { + e := LogEntry{Level: "error", Repo: repo, Action: action, Description: description} + if err != nil { + e.Error = safety.SanitizeText(err.Error()) + } + _ = l.Log(e) +} + +// Success writes a level=success entry. +func (l *Logger) Success(repo, action, description string, duration time.Duration) { + _ = l.Log(LogEntry{ + Level: "success", + Repo: repo, + Action: action, + Description: description, + Duration: duration.String(), + }) +} + +// Close writes end marker and closes file. +func (l *Logger) Close() error { + if l.file == nil { + return nil + } + l.Info("", "git-rain-end", "Git-rain session ended") + _ = l.file.Sync() + return l.file.Close() +} + +// LogPath returns current file path. +func (l *Logger) LogPath() string { return l.logPath } + +// DefaultLogDir resolves standard cache path for rain logs. +func DefaultLogDir() string { + base, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.TempDir(), "git-rain", "logs") + } + return filepath.Join(base, "git-rain", "logs") +} diff --git a/internal/sessionlog/logger_test.go b/internal/sessionlog/logger_test.go new file mode 100644 index 0000000..2d7ab76 --- /dev/null +++ b/internal/sessionlog/logger_test.go @@ -0,0 +1,68 @@ +package sessionlog + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDefaultLogDir(t *testing.T) { + dir := DefaultLogDir() + if dir == "" { + t.Fatal("DefaultLogDir() should not be empty") + } + if !strings.Contains(dir, filepath.Join("git-rain", "logs")) { + t.Fatalf("unexpected DefaultLogDir: %s", dir) + } +} + +func TestLogger_WritesJSONL(t *testing.T) { + logger, err := NewLogger(t.TempDir()) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + logger.Info("repo", "scan", "repo discovered") + logger.Success("repo", "scan-complete", "done", time.Second) + if err := logger.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + data, err := os.ReadFile(logger.LogPath()) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least 3 log lines, got %d", len(lines)) + } + for _, line := range lines { + var entry LogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("invalid JSON line %q: %v", line, err) + } + } +} + +func TestLogger_Subscribe(t *testing.T) { + logger, err := NewLogger(t.TempDir()) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + defer func() { _ = logger.Close() }() + + seen := make(chan LogEntry, 1) + logger.Subscribe(func(e LogEntry) { seen <- e }) + logger.Info("repo", "scan", "repo discovered") + + select { + case entry := <-seen: + if entry.Action != "scan" { + t.Fatalf("entry.Action = %q, want scan", entry.Action) + } + case <-time.After(2 * time.Second): + t.Fatal("did not receive subscribed log event") + } +} diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index b2d1c9d..0bf737e 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -14,6 +14,7 @@ import ( "github.com/git-rain/git-rain/internal/config" "github.com/git-rain/git-rain/internal/git" "github.com/git-rain/git-rain/internal/registry" + "github.com/git-rain/git-rain/internal/sessionlog" ) // ErrCancelled is returned by RunRepoSelector when the user cancels the TUI. @@ -171,6 +172,13 @@ type RepoSelectorModel struct { cfgPath string configCursor int configSaveErr error + + logger *sessionlog.Logger + showLogPanel bool + statusLine string + statusIcon string + logEntries []sessionlog.LogEntry + logExportPath string } // NewRepoSelectorModel creates a new repo selector model (static mode). @@ -208,6 +216,8 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat currentStartupQuote: randomStartupRainQuote(), startupQuoteVisible: true, quoteTickActive: true, + statusLine: "selector ready", + statusIcon: "ℹ️", } } @@ -286,6 +296,8 @@ func NewRepoSelectorModelStream( currentStartupQuote: randomStartupRainQuote(), startupQuoteVisible: showStartupQuote, quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0, + statusLine: "scan starting", + statusIcon: "🔍", } } @@ -303,6 +315,24 @@ func (m RepoSelectorModel) Init() tea.Cmd { return tea.Batch(cmds...) } +func (m *RepoSelectorModel) recordStatus(level, action, description string) { + entry := sessionlog.LogEntry{ + Timestamp: time.Now(), + Level: level, + Action: action, + Description: description, + } + m.statusIcon = statusGlyph(entry) + m.statusLine = description + m.logEntries = append(m.logEntries, entry) + if len(m.logEntries) > 200 { + m.logEntries = m.logEntries[len(m.logEntries)-200:] + } + if m.logger != nil { + _ = m.logger.Log(entry) + } +} + func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -321,15 +351,18 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.scanChan != nil { cmds = append(cmds, waitForRepo(m.scanChan)) } + m.recordStatus("info", "scan-repo", fmt.Sprintf("discovered %s", repo.Name)) case scanProgressMsg: m.scanCurrentPath = string(msg) if m.progressChan != nil && !m.progDone { cmds = append(cmds, waitForProgress(m.progressChan)) } + m.recordStatus("info", "scan-progress", fmt.Sprintf("scanning %s", m.scanCurrentPath)) case repoChanDoneMsg: m.scanDone = true + m.recordStatus("success", "scan-complete", "scan complete") case progressChanDoneMsg: m.progDone = true @@ -479,6 +512,23 @@ func (m RepoSelectorModel) updateMainView(msg tea.KeyMsg, cmds []tea.Cmd) (tea.M m = m.saveConfig() } + case "L": + m.showLogPanel = !m.showLogPanel + if m.showLogPanel { + m.recordStatus("info", "log-panel-open", "log panel opened") + } else { + m.recordStatus("info", "log-panel-close", "log panel closed") + } + + case "e": + path, err := exportLogEntriesText(m.logEntries) + if err != nil { + m.recordStatus("error", "log-export-failed", err.Error()) + } else { + m.logExportPath = path + m.recordStatus("success", "log-exported", fmt.Sprintf("exported log to %s", path)) + } + case "i": m.ignoredEntries = IgnoredRegistryEntries(m.reg) m.ignoredCursor = 0 @@ -1000,8 +1050,10 @@ func RunRepoSelectorStream( cfgPath string, reg *registry.Registry, regPath string, + logger *sessionlog.Logger, ) ([]git.Repository, error) { model := NewRepoSelectorModelStream(scanChan, progressChan, scanDisabled, scanDisabledRunOnly, cfg, cfgPath, reg, regPath) + model.logger = logger p := tea.NewProgram(model, tea.WithAltScreen()) finalModel, err := p.Run() diff --git a/internal/ui/status_log.go b/internal/ui/status_log.go new file mode 100644 index 0000000..82e5132 --- /dev/null +++ b/internal/ui/status_log.go @@ -0,0 +1,61 @@ +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/git-rain/git-rain/internal/sessionlog" +) + +func statusGlyph(e sessionlog.LogEntry) string { + switch e.Level { + case "success": + return "✅" + case "error": + return "❌" + default: + switch { + case strings.Contains(e.Action, "scan"): + return "🔍" + case strings.Contains(e.Action, "export"): + return "📦" + default: + return "ℹ️" + } + } +} + +func renderLogExportText(entries []sessionlog.LogEntry) string { + var b strings.Builder + for _, e := range entries { + ts := e.Timestamp.Format(time.RFC3339) + fmt.Fprintf(&b, "%s [%s] %s %s", ts, e.Level, e.Action, e.Description) + if e.Error != "" { + fmt.Fprintf(&b, " err=%s", e.Error) + } + if e.Duration != "" { + fmt.Fprintf(&b, " duration=%s", e.Duration) + } + b.WriteString("\n") + } + return b.String() +} + +func exportLogEntriesText(entries []sessionlog.LogEntry) (string, error) { + base, err := os.UserCacheDir() + if err != nil { + base = os.TempDir() + } + exportDir := filepath.Join(base, "git-rain", "exports") + if err := os.MkdirAll(exportDir, 0o700); err != nil { + return "", fmt.Errorf("create export dir: %w", err) + } + path := filepath.Join(exportDir, fmt.Sprintf("git-rain-ui-log-%s.txt", time.Now().Format("20060102-150405"))) + if err := os.WriteFile(path, []byte(renderLogExportText(entries)), 0o600); err != nil { + return "", fmt.Errorf("write export file: %w", err) + } + return path, nil +} diff --git a/internal/ui/status_log_test.go b/internal/ui/status_log_test.go new file mode 100644 index 0000000..880be2e --- /dev/null +++ b/internal/ui/status_log_test.go @@ -0,0 +1,45 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/git-rain/git-rain/internal/sessionlog" +) + +func TestStatusGlyphFromEntry(t *testing.T) { + if got := statusGlyph(sessionlog.LogEntry{Level: "success", Action: "scan-complete"}); got != "✅" { + t.Fatalf("statusGlyph(success) = %q, want ✅", got) + } + if got := statusGlyph(sessionlog.LogEntry{Level: "error", Action: "scan-failed"}); got != "❌" { + t.Fatalf("statusGlyph(error) = %q, want ❌", got) + } +} + +func TestRenderLogExportText(t *testing.T) { + out := renderLogExportText([]sessionlog.LogEntry{ + { + Timestamp: time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC), + Level: "info", + Action: "scan-progress", + Description: "found repo", + }, + }) + if !strings.Contains(out, "scan-progress") { + t.Fatalf("missing action in output: %q", out) + } +} + +func TestRepoSelectorModel_ToggleLogPanel(t *testing.T) { + m := NewRepoSelectorModel(nil, nil, "") + if m.showLogPanel { + t.Fatal("showLogPanel should start false") + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'L'}}) + next := updated.(RepoSelectorModel) + if !next.showLogPanel { + t.Fatal("showLogPanel should be true after pressing Shift+L") + } +} diff --git a/internal/ui/view_layout.go b/internal/ui/view_layout.go index a46d351..98322d8 100644 --- a/internal/ui/view_layout.go +++ b/internal/ui/view_layout.go @@ -52,13 +52,43 @@ func (m RepoSelectorModel) mainViewFooterBlock() string { helpText := "\n" + "Controls:\n" + " ↑/k, ↓/j Navigate | ←/→ Scroll path | space Toggle selection\n" + - " m Change mode | x Ignore | a Select all | n Select none | r Toggle rain\n" + + " m Change mode | x Ignore | a Select all | n Select none | r Toggle rain | Shift+L Toggle log panel | e Export logs\n" + " i View ignored | " + configHint + "enter Confirm | q Quit\n\n" + "Icons:\n" + " 💧 = Has uncommitted changes\n" + " [✓] = Selected | [ ] = Not selected | ‹› = path scrollable" var s strings.Builder s.WriteString(helpStyle.MaxWidth(cw).Render(helpText)) + statusStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(activeProfile().scanBorder). + Padding(0, 1) + statusLine := m.statusIcon + " " + m.statusLine + if m.logExportPath != "" { + statusLine += " | last export: " + m.logExportPath + } + s.WriteString("\n") + s.WriteString(statusStyle.MaxWidth(cw).Render(statusLine)) + if m.showLogPanel { + start := 0 + if len(m.logEntries) > 8 { + start = len(m.logEntries) - 8 + } + lines := make([]string, 0, len(m.logEntries)-start) + for _, entry := range m.logEntries[start:] { + lines = append(lines, fmt.Sprintf("%s [%s] %s", entry.Timestamp.Format("15:04:05"), entry.Level, entry.Description)) + } + panelBody := strings.Join(lines, "\n") + if panelBody == "" { + panelBody = "(no events yet)" + } + panelStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(activeProfile().scanBorder). + Padding(0, 1) + s.WriteString("\n") + s.WriteString(panelStyle.MaxWidth(cw).Render(panelBody)) + } if m.scanChan != nil || m.scanDisabled { s.WriteString("\n") s.WriteString(m.renderScanStatus())