package editor import ( "fmt" "log" "sync" "time" "pad/internal/browser" "pad/internal/io/pool" "pad/internal/io/pool/mock" "pad/internal/io/pool/types" "pad/internal/ui" ) // ConfigEvent represents a window configuration change (resize, orientation). // PixelWidth and PixelHeight are the raw pixel dimensions from Gio. type ConfigEvent struct { PixelWidth int PixelHeight int } // ScaleEvent represents a metric change (HiDPI scale factor). type ScaleEvent struct { Scale float32 } // ConfigUpdate is a common interface for all configuration updates. // Both ConfigEvent and ScaleEvent implement this interface. type ConfigUpdate interface { apply(*State) } func (e ConfigEvent) apply(s *State) { s.PixelWidth = e.PixelWidth s.PixelHeight = e.PixelHeight } func (e ScaleEvent) apply(s *State) { s.SetScale(e.Scale) } // ResultEvent represents a completed async task result. type ResultEvent struct { // Future: add result fields here } // Logic runs the logic goroutine and provides channels for communication. type Logic struct { state *State browserManager *browser.BrowserManager configChan chan ConfigUpdate frameChan chan []ui.Element inputChan chan []ui.InputEvent layoutChan chan ui.GlyphLayout resultChan chan ResultEvent searchQueryChan chan string openFileChan chan string retryChan chan string // Added for auto-save retries workerPool *pool.WorkerPool mockFS pool.FileSystem mu sync.Mutex done chan struct{} saveTimer *time.Timer // Added for auto-save debounce saveGeneration int // Added for stale timer filtering } // NewLogic creates a new Logic instance, accepting an optional mockFS. func NewLogic(mfs pool.FileSystem) *Logic { state := NewState() TheState = state // Initialize mock filesystem if nil mockFS := mfs if mockFS == nil { mockFS = mock.NewFileSystem() populateMockFileSystem(mockFS) } // Initialize worker pool wp := pool.NewWorkerPool(4) wp.Start() // Set browser initial path to mock root state.Browser.CurrentPath = "/" bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS) TheLogic = &Logic{ state: state, browserManager: bm, configChan: make(chan ConfigUpdate), frameChan: make(chan []ui.Element, 1), inputChan: make(chan []ui.InputEvent), layoutChan: make(chan ui.GlyphLayout), resultChan: make(chan ResultEvent), searchQueryChan: make(chan string), openFileChan: make(chan string), retryChan: make(chan string, 1), // Buffered channel workerPool: wp, mockFS: mockFS, done: make(chan struct{}), } return TheLogic } // ConfigChan returns the unified config channel for the logic goroutine. // Accepts ConfigEvent (size) and ScaleEvent (scale factor). func (l *Logic) ConfigChan() chan<- ConfigUpdate { return l.configChan } // FrameChan returns the frame channel for the logic goroutine. func (l *Logic) FrameChan() <-chan []ui.Element { return l.frameChan } // InputChan returns the input channel for the logic goroutine. func (l *Logic) InputChan() chan<- []ui.InputEvent { return l.inputChan } // LayoutChan returns the glyph layout feedback channel. func (l *Logic) LayoutChan() chan<- ui.GlyphLayout { return l.layoutChan } // ResultChan returns the result channel for the logic goroutine. func (l *Logic) ResultChan() chan<- ResultEvent { return l.resultChan } // SearchQueryChan returns the search query channel for the logic goroutine. // The main goroutine sends updated search text here when it detects a change. func (l *Logic) SearchQueryChan() chan<- string { return l.searchQueryChan } var TheState *State var TheLogic *Logic // Scale returns the current scale factor (pixels per DP). func (l *Logic) Scale() float32 { return l.state.Scale() } // Run runs the logic goroutine loop. func (l *Logic) Run() { // Dispatch initial directory index build on startup log.Printf("Logic: Dispatching BuildIndexTask") l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS)) for { select { case <-l.done: return case update := <-l.configChan: log.Printf("Logic: ConfigEvent") update.apply(l.state) l.frameChan <- l.state.layout(l.browserManager) case layout := <-l.layoutChan: // Store the full GlyphLayout on editor state. // Derive LastLineY from it for scroll clamping. l.state.Editor.GlyphLayout = layout var derivedLastLineY ui.Dp if len(layout.Y) > 0 { derivedLastLineY = layout.Y[len(layout.Y)-1] } if derivedLastLineY != l.state.LastLineY { l.state.LastLineY = derivedLastLineY l.frameChan <- l.state.layout(l.browserManager) } case events := <-l.inputChan: log.Printf("Logic: InputEvents") for _, evt := range events { evt.Handler(evt.Data) } l.frameChan <- l.state.layout(l.browserManager) case query := <-l.searchQueryChan: log.Printf("Logic: SearchQuery") if query != l.state.Browser.Query { l.state.Browser.Query = query if l.state.page == BrowserPage { browser.HandleSearch(&l.state.Browser, query) } } l.frameChan <- l.state.layout(l.browserManager) case path := <-l.openFileChan: log.Printf("Logic: OpenFileChan %s", path) // Create chunked buffer for virtual scrolling chunkSize := DefaultChunkSize cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "") cb.SetWorkerPool(l.workerPool) TheState.Editor.ChunkedBuffer = cb // Dispatch stat task to get file size l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS)) case filename := <-l.retryChan: log.Printf("Logic: Retrying save for %s", filename) if filename == l.state.Editor.Filename { // Reconstruct full content from chunked buffer for saving var content []byte if l.state.Editor.ChunkedBuffer != nil { fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() if err != nil { log.Printf("Error reconstructing full content for saving %s: %v", filename, err) return } content = []byte(fullContent) } else { content = []byte(l.state.Editor.Buffer) } l.workerPool.DispatchNonBlocking( pool.NewWriteFileTask(filename, content, l.mockFS), ) } case res := <-l.workerPool.ResultChan(): l.handleWorkerResult(res) case <-l.resultChan: l.frameChan <- l.state.layout(l.browserManager) } } } // markDirty triggers the auto-save debounce timer. func (l *Logic) markDirty() { if l.state.Editor.Filename == "" { return } l.state.Editor.fileVersion[l.state.Editor.Filename]++ // 1s debounce if l.saveTimer != nil { l.saveTimer.Stop() } gen := l.saveGeneration l.saveGeneration++ filename := l.state.Editor.Filename l.saveTimer = time.AfterFunc(1*time.Second, func() { log.Printf("Logic: Auto-save timer fired for %s, gen=%d, current=%d", filename, gen+1, l.saveGeneration) if l.saveGeneration == gen+1 { // Reconstruct full content from chunked buffer for saving var content []byte if l.state.Editor.ChunkedBuffer != nil { fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() if err != nil { log.Printf("Error reconstructing full content for auto-save: %v", err) return } content = []byte(fullContent) } else { content = []byte(l.state.Editor.Buffer) } log.Printf("Logic: Dispatching WriteFileTask for %s, content len=%d", filename, len(content)) l.workerPool.DispatchNonBlocking( pool.NewWriteFileTask(filename, content, l.mockFS), ) } }) } // handleWorkerResult processes results from the worker pool. func (l *Logic) handleWorkerResult(res pool.Result) { if res.IsBrowserResult() { l.browserManager.HandleResult(res) } else if res.TaskType == pool.TypeReadFile { log.Printf("Logic: TypeReadFile result success=%v", res.Success) if res.Success { if content, ok := res.Data.([]byte); ok { if l.state.Editor.ChunkedBuffer != nil { // Always populate the chunked buffer and ensure its size is set. l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content))) l.state.Editor.ChunkedBuffer.Insert(0, string(content)) // Also update the Buffer field for backward compatibility and for tests checking it. l.state.Editor.Buffer = string(content) } else { // Fallback: populate the deprecated Buffer field l.state.Editor.Buffer = string(content) } } } } else if res.TaskType == pool.TypeReadChunk { if res.Success { if chunk, ok := res.Data.([]byte); ok { // Parse the task ID to get the chunk index. // Format: "readchunk--" var chunkIdx int fmt.Sscanf(res.TaskID, "readchunk-%d-", &chunkIdx) if cb := l.state.Editor.ChunkedBuffer; cb != nil { cb.chunks[chunkIdx] = chunk log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", chunkIdx, res.FilePath, len(chunk)) } } } else { log.Printf("Logic: ReadChunkTask failed for %s: %v", res.FilePath, res.Error) } } else if res.TaskType == pool.TypeStatFile { log.Printf("Logic: TypeStatFile result success=%v", res.Success) if res.Success { if stat, ok := res.Data.(*pool.FileStat); ok { if l.state.Editor.ChunkedBuffer != nil { l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size) // Load the chunk containing the cursor (position 0 at open) l.state.Editor.ChunkedBuffer.LoadChunk(0) // Prefetch adjacent chunks l.state.Editor.ChunkedBuffer.Prefetch(0, 1) // Dispatch line index build task l.workerPool.Dispatch(pool.NewBuildLineIndexTask(l.state.Editor.Filename, l.mockFS)) } } } } else if res.TaskType == pool.TypeBuildLineIndex { log.Printf("Logic: TypeBuildLineIndex result success=%v", res.Success) if res.Success { if idx, ok := res.Data.(*types.LineIndex); ok { if l.state.Editor.ChunkedBuffer != nil { l.state.Editor.ChunkedBuffer.LineIndex = idx log.Printf("Logic: LineIndex built with %d lines", idx.LineCount()) } } } } else if res.TaskType == pool.TypeWriteFile { if res.Success { l.state.Editor.lastWriteVersion[res.FilePath] = l.state.Editor.fileVersion[res.FilePath] l.state.Editor.SetWriteFailed(res.FilePath, false) } else { log.Printf("Auto-save failed for %s: %v", res.FilePath, res.Error) l.state.Editor.SetWriteFailed(res.FilePath, true) // Trigger retry logic: exponential backoff attempts := l.state.Editor.IncrementRetryAttempts(res.FilePath) // Simple backoff: 1s, 2s, 4s, 8s... max 30s delay := time.Duration(1<<(attempts-1)) * time.Second if delay > 30*time.Second { delay = 30 * time.Second } filename := res.FilePath log.Printf("Scheduling retry for %s in %v (attempt %d)", filename, delay, attempts) time.AfterFunc(delay, func() { log.Printf("Firing retry for %s", filename) l.retryChan <- filename }) } } l.frameChan <- l.state.layout(l.browserManager) } // applyBuildIndexResult applies a completed BuildIndexTask result to browser state. func (l *Logic) applyBuildIndexResult(res pool.Result) { // Delegated to browserManager } // applyLoadPagesResult applies a completed LoadPagesTask result to browser state. func (l *Logic) applyLoadPagesResult(res pool.Result) { // Delegated to browserManager } // applyReadDirResult applies a completed ReadDirTask result to browser state. func (l *Logic) applyReadDirResult(res pool.Result) { // Delegated to browserManager } // State returns the current state. func (l *Logic) State() *State { return l.state } // PruneMaps removes old entries to keep memory usage bounded. // Keeps entries for the last 1000 files. func (l *Logic) PruneMaps() { if len(l.state.Editor.fileVersion) <= 1000 { return } // Simply clear the maps for now. A true LRU would require // tracking access times. l.state.Editor.fileVersion = make(map[string]int) l.state.Editor.lastWriteVersion = make(map[string]int) l.state.Editor.writeFailed = make(map[string]bool) l.state.Editor.retryAttempts = make(map[string]int) } // Done signals the logic goroutine to stop. func (l *Logic) Done() { close(l.done) } // FlushAll triggers synchronous writes for all dirty files. func (l *Logic) FlushAll() { l.mu.Lock() defer l.mu.Unlock() for filename := range l.state.Editor.fileVersion { if l.state.Editor.IsDirty() && l.state.Editor.Filename == filename { // Reconstruct full content from chunked buffer for saving var content []byte if l.state.Editor.ChunkedBuffer != nil { fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() if err != nil { log.Printf("Error reconstructing full content for flush: %v", err) return } content = []byte(fullContent) } else { content = []byte(l.state.Editor.Buffer) } // In a real app, this would be a blocking call to the FS l.mockFS.WriteFileAtomic(filename, content) l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename] } } }