package editor
import (
"log"
"os"
"path/filepath"
"strconv"
"strings"
"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.
//
// Single-owner invariant (architecture.md §1): the logic goroutine is the
// sole reader/writer of l.state. Every other goroutine talks to it through
// the channels below. The only exception is Inspect, a test-only request
// channel whose fn still executes on the owner.
type Logic struct {
state *State
browserManager *browser.BrowserManager
configChan chan ConfigUpdate
frameChan chan Frame // frames carry the view-state snapshot
inputChan chan []ui.InputEvent
layoutChan chan ui.GlyphLayout
resultChan chan ResultEvent
searchQueryChan chan string
openFileChan chan string
retryChan chan string // auto-save retries
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
inspectChan chan *inspectReq
workerPool *pool.WorkerPool
mockFS pool.FileSystem
done chan struct{}
exitWg sync.WaitGroup
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
lastEmit time.Time // time of the last frame emission (profiler cadence)
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
}
// NewLogic creates a new Logic instance, accepting an optional mockFS.
func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
state := NewState()
TheState = state
// The openfunc (e.g. the Android Termux bridge) is kept on TheState.open
// as an optional external hook, but it is NOT the tap path: tapping a file
// must open it in the in-app editor (doc/spec.md). The previous wiring set
// ui.OpenFile = openfunc, which routed every tap through the external
// bridge and bypassed the editor entirely.
TheState.open = openfunc
ui.OpenFile = func(path string) { OpenFile(path) }
// Initialize mock filesystem if nil
mockFS := mfs
if mockFS == nil {
mockFS = mock.NewFileSystem()
populateMockFileSystem(mockFS)
}
// Initialize worker pool
wp := pool.NewWorkerPool(8) // Increased worker pool size to 8
wp.Start()
state.Browser.CurrentPath = path
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
TheLogic = &Logic{
state: state,
browserManager: bm,
configChan: make(chan ConfigUpdate),
frameChan: make(chan Frame, 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
autosaveChan: make(chan struct{}),
inspectChan: make(chan *inspectReq),
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 Frame {
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
// Run runs the logic goroutine loop.
func (l *Logic) Run() {
l.exitWg.Add(1)
defer l.exitWg.Done()
// 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.emitFrame()
case layout := <-l.layoutChan:
// Store the full GlyphLayout on editor state.
// Derive LastLineY from it for scroll clamping.
l.state.Editor.GlyphLayout = layout
log.Printf("LOGIC received GlyphLayout: ByteOffsets=%d, VisualLineStarts=%d", len(layout.ByteOffsets), len(layout.VisualLineStarts))
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.emitFrame()
}
case events := <-l.inputChan:
log.Printf("Logic: InputEvents")
for _, evt := range events {
evt.Handler(evt.Data)
}
l.emitFrame()
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.emitFrame()
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
content, ok := l.fullContentBytes()
if !ok {
break
}
l.workerPool.DispatchNonBlocking(
pool.NewWriteFileTask(filename, content, l.mockFS),
)
}
case <-l.autosaveChan:
// Auto-save debounce tick. The timer goroutine only sent a token;
// the owner reconstructs content and dispatches the write.
l.saveTimer = nil
if l.state.Editor.Filename == "" {
break
}
content, ok := l.fullContentBytes()
if !ok {
break
}
log.Printf("Logic: Dispatching WriteFileTask for %s, content len=%d", l.state.Editor.Filename, len(content))
l.workerPool.DispatchNonBlocking(
pool.NewWriteFileTask(l.state.Editor.Filename, content, l.mockFS),
)
case req := <-l.inspectChan:
// Test-only: fn runs on the owner, preserving single ownership.
req.resp <- req.fn(l.state)
case res := <-l.workerPool.ResultChan():
l.handleWorkerResult(res)
case <-l.resultChan:
l.emitFrame()
case cmd := <-l.debugCmdC:
l.applyDebugCmd(cmd)
}
}
}
// emitFrame computes the current frame, records a profiler probe (if enabled),
// and hands it to the main goroutine. Centralizing emission here ensures the
// in-app profiler (PerfRecord) sees every frame exactly once, on the owner
// goroutine. Must be called on the logic goroutine.
func (l *Logic) emitFrame() {
elems := l.state.layout(l.browserManager)
now := time.Now()
if PerfRecord != nil {
var delta time.Duration
if !l.lastEmit.IsZero() {
delta = now.Sub(l.lastEmit)
}
l.lastEmit = now
s := l.state
rec := ProbeRecord{T: now, DeltaMs: float64(delta.Nanoseconds()) / 1e6, Page: pageName(s.page)}
if s.page == EditorPage {
rec.ScrollDP = float32(s.ScrollOffset)
rec.MaxScrollDP = float32(s.MaxScroll)
rec.VisStart = s.VisibleStart
rec.VisEnd = s.VisibleEnd
if cb := s.Editor.ChunkedBuffer; cb != nil {
if li := cb.LineIndex; li != nil {
rec.TotalLines = li.LineCount()
}
}
} else {
rec.ScrollDP = float32(s.Browser.ScrollOffset)
}
PerfRecord(rec)
}
l.frameChan <- l.frameOf(elems)
}
// EnableDebugCmdPoll starts a background poller (debug-only) that watches
//
/cmd for a one-shot scroll command and forwards it to the owner for
// application. Used to jump the editor to specific scroll offsets for
// performance/clamping validation. The poller does the (blocking) file read
// off the owner and sends the command via debugCmdC; the owner applies it.
func (l *Logic) EnableDebugCmdPoll(dir string) {
l.debugCmdC = make(chan string, 1)
l.exitWg.Add(1)
go func() {
defer l.exitWg.Done()
t := time.NewTicker(120 * time.Millisecond)
defer t.Stop()
cmdPath := filepath.Join(dir, "cmd")
for {
select {
case <-l.done:
return
case <-t.C:
b, err := os.ReadFile(cmdPath)
if err != nil {
continue
}
cmd := strings.TrimSpace(string(b))
if cmd == "" {
continue
}
// Consume the command so it is applied exactly once.
_ = os.WriteFile(cmdPath, nil, 0o644)
select {
case l.debugCmdC <- cmd:
case <-l.done:
return
}
}
}
}()
}
// applyDebugCmd applies a one-shot debug scroll command from the cmd-file
// poller. Commands: "top", "bottom", "frac <0..1>", "dp ". Must be
// called on the logic goroutine.
func (l *Logic) applyDebugCmd(cmd string) {
s := l.state
if s.page != EditorPage {
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
return
}
fields := strings.Fields(cmd)
if len(fields) == 0 {
return
}
var target ui.Dp
switch fields[0] {
case "top":
target = 0
case "bottom":
target = s.MaxScroll
case "frac":
if len(fields) < 2 {
return
}
f, err := strconv.ParseFloat(fields[1], 64)
if err != nil || f < 0 || f > 1 {
log.Printf("DebugCmd: bad frac %q", fields[1])
return
}
target = ui.Dp(float64(f) * float64(s.MaxScroll))
case "dp":
if len(fields) < 2 {
return
}
n, err := strconv.Atoi(fields[1])
if err != nil {
log.Printf("DebugCmd: bad dp %q", fields[1])
return
}
target = ui.Dp(n)
default:
log.Printf("DebugCmd: unknown %q", cmd)
return
}
if target < 0 {
target = 0
}
if target > s.MaxScroll {
target = s.MaxScroll
}
s.ScrollOffset = target
log.Printf("DebugCmd: %q -> scroll=%d maxScroll=%d", cmd, int(s.ScrollOffset), int(s.MaxScroll))
l.emitFrame()
}
// fullContentBytes reconstructs the full file content from the chunked
// buffer (or the deprecated full Buffer). Returns ok=false on error.
// Must be called on the logic goroutine.
func (l *Logic) fullContentBytes() ([]byte, bool) {
if l.state.Editor.ChunkedBuffer != nil {
fullContent, err := l.state.Editor.ChunkedBuffer.FullContent()
if err != nil {
log.Printf("Error reconstructing full content: %v", err)
return nil, false
}
return []byte(fullContent), true
}
return []byte(l.state.Editor.Buffer), true
}
// markDirty triggers the auto-save debounce timer.
// Must be called on the logic goroutine.
//
// The timer callback runs on a timer goroutine and only sends a token on
// autosaveChan; the owner (Run loop) does all state reads and the worker
// dispatch. This keeps the timer goroutine out of state (architecture.md §1).
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()
}
l.saveTimer = time.AfterFunc(1*time.Second, func() {
l.autosaveChan <- struct{}{}
})
}
// 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 {
// Full-load the in-range file: set the whole content (split into
// resident chunks). No lazy load, so no stale-disk re-read.
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
l.state.Editor.ChunkedBuffer.SetContent(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 {
// In-range files load fully via SetContent, so this is only a fallback.
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
if res.Success {
if chunk, ok := res.Data.([]byte); ok {
for len(cb.chunks) <= res.ChunkIdx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[res.ChunkIdx] = chunk
log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk))
}
} else {
log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, 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 {
// Size guard: refuse to edit files above the limit. The browser can
// still list them; the editor shows a "too large to edit" notice.
if stat.Size > MaxEditableFileSize {
l.state.Editor.TooLarge = true
l.state.Editor.TooLargeSize = stat.Size
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
l.emitFrame()
return
}
if l.state.Editor.ChunkedBuffer != nil {
l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size)
// Full-load: read the whole file and dispatch a line index build.
l.workerPool.Dispatch(pool.NewReadFileTask(stat.Path, l.mockFS))
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, 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.emitFrame()
}
// 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)
}
// WaitForExit blocks until the logic goroutine has fully stopped. After
// this returns, the caller (e.g. Shutdown) may touch state single-threaded.
func (l *Logic) WaitForExit() {
l.exitWg.Wait()
}
// FlushAll triggers synchronous writes for all dirty files.
//
// Must be called either from the logic goroutine (e.g. via GoToBrowser) or
// after the logic goroutine has fully stopped (Shutdown). It touches state
// directly, so it must never run concurrently with Run (single-owner
// invariant, architecture.md §1).
func (l *Logic) FlushAll() {
for filename := range l.state.Editor.fileVersion {
if l.state.Editor.IsDirty() && l.state.Editor.Filename == filename {
content, ok := l.fullContentBytes()
if !ok {
continue
}
// 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]
}
}
}
// Shutdown gracefully shuts down the logic goroutine and worker pool.
// The logic goroutine is stopped FIRST so that FlushAll can touch state
// single-threaded; the worker pool is stopped last so in-flight results
// still have a reader until then.
func (l *Logic) Shutdown() {
l.Done()
l.WaitForExit()
l.FlushAll()
l.workerPool.Stop()
}