- ui.OpenFile now calls the in-app editor OpenFile(path) instead of the external openfunc (Android Termux bridge). The old wiring sent every browser-row tap through the Termux bridge, which crashed on Android 7+ with FileUriExposedException (file:// Intent URI) and bypassed the editor. - OpenFile no longer invokes TheState.open; it is retained as a dormant external hook for a future 'open externally' action. - Add imeDebugLog (off by default) verbose per-commit IME logging in HandleReplaceRange, plus currentEditorText helper, to aid on-device IME commit/cursor-sync debugging.
446 lines
14 KiB
Go
446 lines
14 KiB
Go
package editor
|
|
|
|
import (
|
|
"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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
// 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.frameChan <- l.frameOf(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
|
|
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.frameChan <- l.frameOf(l.state.layout(l.browserManager))
|
|
}
|
|
case events := <-l.inputChan:
|
|
log.Printf("Logic: InputEvents")
|
|
for _, evt := range events {
|
|
evt.Handler(evt.Data)
|
|
}
|
|
l.frameChan <- l.frameOf(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.frameOf(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
|
|
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.frameChan <- l.frameOf(l.state.layout(l.browserManager))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// 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 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 {
|
|
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 {
|
|
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.frameOf(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)
|
|
}
|
|
|
|
// 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()
|
|
}
|