From 7f07aa0158d3cd878820b160cbedaaa28ee802e9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 15 Mar 2026 15:16:07 -0500 Subject: [PATCH] feat: real-time log viewer and add facet parsing and rendering for post content --- TODO.md | 16 +- app.go | 15 + frontend/src/App.svelte | 11 + frontend/src/lib/components/DataTable.svelte | 5 +- frontend/src/lib/components/LogViewer.svelte | 165 +++++++++++ frontend/src/lib/components/PostText.svelte | 61 ++++ frontend/src/lib/facets.ts | 161 +++++++++++ log_service.go | 278 +++++++++++++++++++ logger.go | 134 +++++++++ main.go | 2 +- 10 files changed, 838 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/LogViewer.svelte create mode 100644 frontend/src/lib/components/PostText.svelte create mode 100644 frontend/src/lib/facets.ts create mode 100644 log_service.go create mode 100644 logger.go diff --git a/TODO.md b/TODO.md index d60fc65..102a608 100644 --- a/TODO.md +++ b/TODO.md @@ -50,14 +50,14 @@ ## Milestone 6 — Facets & Log Viewer -- [ ] Frontend: facet parser — convert UTF-8 byte offsets to JS string indices -- [ ] Frontend: facet renderer — links (``), mentions (`@handle`), hashtags (`#tag`) -- [ ] Integrate rendered facets into post text in data table rows -- [ ] Implement `LogService` — custom `io.Writer` that emits `log:line` events -- [ ] Wire `LogService` writer into `log.NewWithOptions` alongside file writer -- [ ] Frontend: log viewer panel with terminal-style dark background, monospace text -- [ ] Auto-scroll to bottom with scroll-lock toggle -- [ ] Level filter buttons: Debug, Info, Warn, Error +- [x] Frontend: facet parser — convert UTF-8 byte offsets to JS string indices +- [x] Frontend: facet renderer — links (``), mentions (`@handle`), hashtags (`#tag`) +- [x] Integrate rendered facets into post text in data table rows +- [x] Implement `LogService` — custom `io.Writer` that emits `log:line` events +- [x] Wire `LogService` writer into `log.NewWithOptions` alongside file writer +- [x] Frontend: log viewer panel with terminal-style dark background, monospace text +- [x] Auto-scroll to bottom with scroll-lock toggle +- [x] Level filter buttons: Debug, Info, Warn, Error ## Milestone 7 — Polish diff --git a/app.go b/app.go index 7025faf..ed6b617 100644 --- a/app.go +++ b/app.go @@ -14,6 +14,7 @@ type App struct { authService *AuthService indexService *IndexService searchService *SearchService + logService *LogService } // NewApp creates a new App application struct @@ -22,6 +23,7 @@ func NewApp() *App { authService: NewAuthService(), indexService: NewIndexService(), searchService: NewSearchService(), + logService: NewLogService(), } } @@ -31,6 +33,16 @@ func (a *App) startup(ctx context.Context) { a.ctx = ctx a.indexService.SetContext(ctx) + a.logService.SetContext(ctx) + + // Initialize log service first + if err := a.logService.Initialize(); err != nil { + runtime.LogErrorf(a.ctx, "failed to initialize log service: %v", err) + } else { + // Initialize the global logger with our log service + InitLogger(a.logService) + LogInfo("Application started") + } dbPath := getDBPath() if err := Open(dbPath); err != nil { @@ -47,6 +59,9 @@ func (a *App) startup(ctx context.Context) { // shutdown is called when the app shuts down func (a *App) shutdown(ctx context.Context) { + if err := a.logService.Close(); err != nil { + runtime.LogErrorf(ctx, "failed to close log service: %v", err) + } if err := Close(); err != nil { runtime.LogErrorf(ctx, "failed to close database: %v", err) } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index cb4f4a4..0323538 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,6 +9,7 @@ import { EventsOn } from "../wailsjs/runtime/runtime"; import SearchBar from "./lib/components/SearchBar.svelte"; import DataTable from "./lib/components/DataTable.svelte"; + import LogViewer from "./lib/components/LogViewer.svelte"; import type { main } from "../wailsjs/go/models"; type AuthInfo = { handle: string; did: string }; @@ -31,6 +32,7 @@ let sortColumn = $state("created_at"); let sortDirection = $state<"asc" | "desc">("desc"); let isSearching = $state(false); + let showLogs = $state(false); onMount(async () => { await checkAuthStatus(); @@ -229,6 +231,12 @@
+ +
+ + + {#if showProgress}
diff --git a/frontend/src/lib/components/DataTable.svelte b/frontend/src/lib/components/DataTable.svelte index 583e941..ff38cbe 100644 --- a/frontend/src/lib/components/DataTable.svelte +++ b/frontend/src/lib/components/DataTable.svelte @@ -1,6 +1,7 @@ + +{#if visible} +
+ +
+
+ Logs + ({logs.length}) +
+ +
+ +
+ {#each ["ALL", ...levels] as level} + + {/each} +
+ + + + + + +
+
+ + +
+ {#each filteredLogs() as log} +
+ {formatTimestamp(log.timestamp)} + [{log.level}] + {log.message} +
+ {:else} +
No logs
+ {/each} +
+
+{/if} diff --git a/frontend/src/lib/components/PostText.svelte b/frontend/src/lib/components/PostText.svelte new file mode 100644 index 0000000..26b4d7e --- /dev/null +++ b/frontend/src/lib/components/PostText.svelte @@ -0,0 +1,61 @@ + + + + {#each renderedFacets as facet} + {#if facet.type === 'link'} +
e.stopPropagation()}> + {facet.text} + + {:else if facet.type === 'mention'} + e.stopPropagation()}> + {facet.text} + + {:else if facet.type === 'tag'} + e.stopPropagation()}> + {facet.text} + + {:else} + {facet.text} + {/if} + {/each} + diff --git a/frontend/src/lib/facets.ts b/frontend/src/lib/facets.ts new file mode 100644 index 0000000..2711e68 --- /dev/null +++ b/frontend/src/lib/facets.ts @@ -0,0 +1,161 @@ +export type FacetFeature = { [key: string]: any; $type: string }; + +export type FacetByteSlice = { byteStart: number; byteEnd: number }; + +export interface Facet { + index: FacetByteSlice; + features: FacetFeature[]; +} + +type FacetKind = "link" | "mention" | "tag" | "text"; + +export type RenderedFacet = { type: FacetKind; text: string; href?: string; did?: string; tag?: string }; + +/** + * Convert UTF-8 byte offsets to JS string indices (UTF-16 code units) + */ +function byteOffsetToCharIndex(text: string, byteOffset: number): number { + const encoder = new TextEncoder(); + let currentByte = 0; + + for (const [i, char] of Array.from(text).entries()) { + const charBytes = encoder.encode(char).length; + + if (currentByte >= byteOffset) { + return i; + } + + currentByte += charBytes; + } + + return text.length; +} + +/** + * Parse a facets JSON string and return parsed Facet objects + */ +export function parseFacets(facetsJson: string): Facet[] { + if (!facetsJson) return []; + + try { + const parsed = JSON.parse(facetsJson); + if (Array.isArray(parsed)) { + return parsed as Facet[]; + } + } catch (e) { + console.warn("Failed to parse facets:", e); + } + + return []; +} + +/** + * Render facets into an array of RenderedFacet objects + * This converts byte offsets to JS string indices and extracts the text segments + */ +export function renderFacets(text: string, facets: Facet[]): RenderedFacet[] { + if (!facets || facets.length === 0) { + return [{ type: "text", text }]; + } + + const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); + + const result: RenderedFacet[] = []; + let lastByteEnd = 0; + + for (const facet of sortedFacets) { + if (facet.index.byteStart > lastByteEnd) { + const beforeStart = byteOffsetToCharIndex(text, lastByteEnd); + const beforeEnd = byteOffsetToCharIndex(text, facet.index.byteStart); + const beforeText = text.slice(beforeStart, beforeEnd); + if (beforeText) { + result.push({ type: "text", text: beforeText }); + } + } + + const facetStart = byteOffsetToCharIndex(text, facet.index.byteStart); + const facetEnd = byteOffsetToCharIndex(text, facet.index.byteEnd); + const facetText = text.slice(facetStart, facetEnd); + + for (const feature of facet.features) { + const type = feature.$type; + + if (type === "app.bsky.richtext.facet#link") { + result.push({ type: "link", text: facetText, href: feature.uri }); + } else if (type === "app.bsky.richtext.facet#mention") { + result.push({ + type: "mention", + text: facetText, + did: feature.did, + href: `https://bsky.app/profile/${feature.did}`, + }); + } else if (type === "app.bsky.richtext.facet#tag") { + result.push({ + type: "tag", + text: facetText, + tag: feature.tag, + href: `https://bsky.app/search?q=%23${encodeURIComponent(feature.tag)}`, + }); + } else { + result.push({ type: "text", text: facetText }); + } + + // TODO: parse ALL features + break; + } + + lastByteEnd = facet.index.byteEnd; + } + + const encoder = new TextEncoder(); + const textBytes = encoder.encode(text).length; + if (lastByteEnd < textBytes) { + const remainingStart = byteOffsetToCharIndex(text, lastByteEnd); + const remainingText = text.slice(remainingStart); + if (remainingText) { + result.push({ type: "text", text: remainingText }); + } + } + + return result; +} + +/** + * Get plain text with facets stripped (for truncation) + */ +export function getPlainText(text: string, facets: Facet[]): string { + return text; +} + +/** + * Truncate rendered facets to a maximum length while preserving facet boundaries + */ +export function truncateRenderedFacets( + rendered: RenderedFacet[], + maxLen: number, +): { facets: RenderedFacet[]; truncated: boolean } { + let currentLength = 0; + const result: RenderedFacet[] = []; + let truncated = false; + + for (const facet of rendered) { + const remaining = maxLen - currentLength; + + if (remaining <= 0) { + truncated = true; + break; + } + + if (facet.text.length <= remaining) { + result.push(facet); + currentLength += facet.text.length; + } else { + const truncatedText = facet.text.slice(0, remaining) + "..."; + result.push({ ...facet, text: truncatedText }); + truncated = true; + break; + } + } + + return { facets: result, truncated }; +} diff --git a/log_service.go b/log_service.go new file mode 100644 index 0000000..f496996 --- /dev/null +++ b/log_service.go @@ -0,0 +1,278 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// LogLevel represents the severity of a log message +type LogLevel string + +const ( + DebugLevel LogLevel = "DEBUG" + InfoLevel LogLevel = "INFO" + WarnLevel LogLevel = "WARN" + ErrorLevel LogLevel = "ERROR" +) + +// LogEntry represents a single log entry +type LogEntry struct { + Level string `json:"level"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// LogService provides logging functionality via Wails bindings +type LogService struct { + ctx context.Context + mu sync.RWMutex + entries []LogEntry + maxEntries int + file *os.File + writer *LogWriter + level LogLevel +} + +// LogWriter implements io.Writer and emits Wails events +type LogWriter struct { + service *LogService + mu sync.Mutex +} + +func (w *LogWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + lines := strings.Split(string(p), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Parse the log level from the line + level := w.parseLevel(line) + + entry := LogEntry{ + Level: string(level), + Message: line, + Timestamp: time.Now(), + } + + w.service.addEntry(entry) + w.service.emitLogLine(entry) + } + + return len(p), nil +} + +func (w *LogWriter) parseLevel(line string) LogLevel { + upper := strings.ToUpper(line) + if strings.Contains(upper, "ERROR") || strings.Contains(upper, "ERR") { + return ErrorLevel + } + if strings.Contains(upper, "WARN") || strings.Contains(upper, "WARNING") { + return WarnLevel + } + if strings.Contains(upper, "DEBUG") || strings.Contains(upper, "DBG") { + return DebugLevel + } + return InfoLevel +} + +// NewLogService creates a new LogService instance +func NewLogService() *LogService { + return &LogService{ + entries: make([]LogEntry, 0), + maxEntries: 1000, + level: InfoLevel, + } +} + +// SetContext sets the Wails context for event emission +func (s *LogService) SetContext(ctx context.Context) { + s.ctx = ctx +} + +// Initialize sets up the log service with a file writer +func (s *LogService) Initialize() error { + // Open log file + logPath := s.getLogPath() + logDir := filepath.Dir(logPath) + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + s.file = file + + // Create the log writer + s.writer = &LogWriter{service: s} + + return nil +} + +// Close closes the log file +func (s *LogService) Close() error { + if s.file != nil { + return s.file.Close() + } + return nil +} + +// GetWriter returns the io.Writer for logging +func (s *LogService) GetWriter() io.Writer { + if s.writer == nil { + return io.Discard + } + return s.writer +} + +// GetMultiWriter returns a MultiWriter that writes to both the log file and the event emitter +func (s *LogService) GetMultiWriter() io.Writer { + if s.file == nil || s.writer == nil { + return io.Discard + } + return io.MultiWriter(s.file, s.writer) +} + +// GetEntries returns all log entries +func (s *LogService) GetEntries() []LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]LogEntry, len(s.entries)) + copy(result, s.entries) + return result +} + +// GetEntriesByLevel returns log entries filtered by level +func (s *LogService) GetEntriesByLevel(level string) []LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + var result []LogEntry + for _, entry := range s.entries { + if entry.Level == level { + result = append(result, entry) + } + } + return result +} + +// Clear clears all log entries +func (s *LogService) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + + s.entries = make([]LogEntry, 0) + s.emitLogCleared() +} + +// SetLevel sets the minimum log level +func (s *LogService) SetLevel(level string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.level = LogLevel(level) +} + +// GetLevel returns the current log level +func (s *LogService) GetLevel() string { + s.mu.RLock() + defer s.mu.RUnlock() + + return string(s.level) +} + +func (s *LogService) addEntry(entry LogEntry) { + s.mu.Lock() + defer s.mu.Unlock() + + // Only add if level is >= current level + if !s.shouldLog(entry.Level) { + return + } + + s.entries = append(s.entries, entry) + + // Trim if exceeding max entries + if len(s.entries) > s.maxEntries { + s.entries = s.entries[len(s.entries)-s.maxEntries:] + } +} + +func (s *LogService) shouldLog(entryLevel string) bool { + levels := map[LogLevel]int{ + DebugLevel: 0, + InfoLevel: 1, + WarnLevel: 2, + ErrorLevel: 3, + } + + entryIdx := levels[LogLevel(entryLevel)] + currentIdx := levels[s.level] + + return entryIdx >= currentIdx +} + +func (s *LogService) emitLogLine(entry LogEntry) { + if s.ctx != nil { + runtime.EventsEmit(s.ctx, "log:line", entry) + } +} + +func (s *LogService) emitLogCleared() { + if s.ctx != nil { + runtime.EventsEmit(s.ctx, "log:cleared", map[string]any{}) + } +} + +func (s *LogService) getLogPath() string { + if path := os.Getenv("BSKY_BROWSER_LOG"); path != "" { + return path + } + + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + home, _ := os.UserHomeDir() + configDir = filepath.Join(home, ".config") + } + + appDir := filepath.Join(configDir, "bsky-browser", "logs") + timestamp := time.Now().Format("2006-01-02_15-04-05") + return filepath.Join(appDir, fmt.Sprintf("bsky-browser_%s.log", timestamp)) +} + +// BufferedLogWriter wraps a writer with buffering for better performance +type BufferedLogWriter struct { + writer *bufio.Writer + service *LogService +} + +func NewBufferedLogWriter(service *LogService) *BufferedLogWriter { + return &BufferedLogWriter{ + writer: bufio.NewWriter(service.GetMultiWriter()), + service: service, + } +} + +func (w *BufferedLogWriter) Write(p []byte) (n int, err error) { + return w.writer.Write(p) +} + +func (w *BufferedLogWriter) Flush() error { + return w.writer.Flush() +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..c8da25b --- /dev/null +++ b/logger.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "log" + "os" +) + +// AppLogger provides application logging that writes to both file and Wails events +type AppLogger struct { + stdLogger *log.Logger + service *LogService +} + +var appLogger *AppLogger + +// InitLogger initializes the application logger with the log service +func InitLogger(service *LogService) { + multiWriter := service.GetMultiWriter() + appLogger = &AppLogger{ + stdLogger: log.New(multiWriter, "", log.LstdFlags|log.Lshortfile), + service: service, + } + + // Redirect standard log to our logger + log.SetOutput(multiWriter) +} + +// GetLogger returns the global app logger +func GetLogger() *AppLogger { + if appLogger == nil { + // Fallback to stdout if not initialized + return &AppLogger{ + stdLogger: log.New(os.Stdout, "", log.LstdFlags), + } + } + return appLogger +} + +// Debug logs a debug message +func (l *AppLogger) Debug(msg string) { + l.stdLogger.Printf("[DEBUG] %s", msg) +} + +// Debugf logs a formatted debug message +func (l *AppLogger) Debugf(format string, args ...interface{}) { + l.stdLogger.Printf("[DEBUG] "+format, args...) +} + +// Info logs an info message +func (l *AppLogger) Info(msg string) { + l.stdLogger.Printf("[INFO] %s", msg) +} + +// Infof logs a formatted info message +func (l *AppLogger) Infof(format string, args ...interface{}) { + l.stdLogger.Printf("[INFO] "+format, args...) +} + +// Warn logs a warning message +func (l *AppLogger) Warn(msg string) { + l.stdLogger.Printf("[WARN] %s", msg) +} + +// Warnf logs a formatted warning message +func (l *AppLogger) Warnf(format string, args ...interface{}) { + l.stdLogger.Printf("[WARN] "+format, args...) +} + +// Error logs an error message +func (l *AppLogger) Error(msg string) { + l.stdLogger.Printf("[ERROR] %s", msg) +} + +// Errorf logs a formatted error message +func (l *AppLogger) Errorf(format string, args ...interface{}) { + l.stdLogger.Printf("[ERROR] "+format, args...) +} + +// Fatal logs a fatal message and exits +func (l *AppLogger) Fatal(msg string) { + l.stdLogger.Fatalf("[FATAL] %s", msg) +} + +// Fatalf logs a formatted fatal message and exits +func (l *AppLogger) Fatalf(format string, args ...interface{}) { + l.stdLogger.Fatalf("[FATAL] "+format, args...) +} + +// Convenience functions that use the global logger +func LogDebug(msg string) { + GetLogger().Debug(msg) +} + +func LogDebugf(format string, args ...interface{}) { + GetLogger().Debugf(format, args...) +} + +func LogInfo(msg string) { + GetLogger().Info(msg) +} + +func LogInfof(format string, args ...interface{}) { + GetLogger().Infof(format, args...) +} + +func LogWarn(msg string) { + GetLogger().Warn(msg) +} + +func LogWarnf(format string, args ...interface{}) { + GetLogger().Warnf(format, args...) +} + +func LogError(msg string) { + GetLogger().Error(msg) +} + +func LogErrorf(format string, args ...interface{}) { + GetLogger().Errorf(format, args...) +} + +func LogFatal(msg string) { + GetLogger().Fatal(msg) +} + +func LogFatalf(format string, args ...interface{}) { + GetLogger().Fatalf(format, args...) +} + +// String returns a string representation of the logger +func (l *AppLogger) String() string { + return fmt.Sprintf("AppLogger{initialized: %v}", l.service != nil) +} diff --git a/main.go b/main.go index d28808a..1906830 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, OnStartup: app.startup, OnShutdown: app.shutdown, - Bind: []any{app, app.authService, app.indexService, app.searchService}, + Bind: []any{app, app.authService, app.indexService, app.searchService, app.logService}, }) if err != nil { -- knotmirror 0.0.1