On-device runs exposed three bugs the e2e suite could not (it never feeds layout feedback, and runs headless without size events): 1. WrapIndex poisoning from zero-width shapes. The first frames are built before the window size is known (px=0x0); the renderer shapes the editor window at zero width, where every line wraps into many visual lines. When that feedback arrives, applyWrapCounts writes the inflated counts to the window's lines. Normally the app stays on those lines and re-shapes them at a real width, which corrects the counts before anyone notices. A restored scroll moves the viewport away instead, so the poisoned counts persist and map the restored scroll offset to the wrong line (2000 landed on line 11 of 200). The frame now carries ViewportDegenerate (set at frame-build time, since feedback delivery lags shaping by a frame), and the main loop drops layout feedback for such frames. 2. Session saving suppressed forever after a successful restore. saveSessionIfChanged suppresses saves while l.session is set, and only abortRestore cleared it — the success path never did, so an app restored from a session never persisted new state. The snapshot has fully landed once the cursor/selection/find have landed with the content and the armed scroll has landed (or there was none); clear l.session at both points. 3. gofmt on restore_test.go (comment alignment).
890 lines
31 KiB
Go
890 lines
31 KiB
Go
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
|
|
// FontScale is the user font-size setting (Metric.PxPerSp / PxPerDp).
|
|
// The shaper draws baselines in sp, so the rendered line pitch in
|
|
// density-dp is EditorLineHeight()*FontScale; all geometry bookkeeping
|
|
// follows it (see EffectiveLineHeight). 0 = unknown, treated as 1.0.
|
|
FontScale 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)
|
|
s.SetFontScale(e.FontScale)
|
|
}
|
|
|
|
// 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.LayoutFeedback
|
|
resultChan chan ResultEvent
|
|
searchQueryChan chan string
|
|
findQueryChan chan string // in-file search text (main-owned "find_bar")
|
|
openFileChan chan string
|
|
retryChan chan string // auto-save retries
|
|
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
|
|
inspectChan chan *inspectReq
|
|
|
|
// Per-file write protocol (see requestSave). Workers are a shared pool and
|
|
// the on-disk staging file is per-file, so two concurrent writes for the
|
|
// same file would interleave on the temp file; the protocol keeps at most
|
|
// one write in flight per file and re-issues deferred saves from the write
|
|
// result, so the rename that lands last always carries the newest content.
|
|
// All three maps are touched only on the owner goroutine.
|
|
writeInFlight map[string]int // filename -> fileVersion carried by the in-flight write
|
|
savePending map[string]bool // filename -> save requested while a write was in flight
|
|
retryScheduled map[string]time.Time // filename -> armed-but-unfired backoff retry
|
|
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
|
|
|
|
// Relaunch state restoration (spec §7, see session.go): session is the
|
|
// snapshot handed in by the cmd layer via BeginRestore (zero = none),
|
|
// restoreFile names the open whose stat/read results are the restore's
|
|
// ("" = none; any other open cancels it). sessionSaver is the cmd layer's
|
|
// file writer; lastSession/lastSessionSave drive the rate-limited
|
|
// change-detected save from emitFrame. All touched only on the owner.
|
|
session SessionState
|
|
restoreFile string
|
|
// restoreScroll/restoreScrollArmed hold the snapshot's scroll offset
|
|
// until it is safe to apply it: only on an emitFrame whose layout pass
|
|
// saw a trustworthy viewport (scale known, size known, restored content
|
|
// present — the layout computes MaxScroll and one-way-clamps the offset,
|
|
// and on device the first ScaleEvent can precede the size ConfigEvent).
|
|
// scaleSeen tracks the first ScaleEvent; restoreContentLanded marks the
|
|
// read result that filled the buffer (FileLen alone is set earlier, by
|
|
// the stat result, and is not a content-arrival signal).
|
|
restoreScroll ui.Dp
|
|
restoreScrollArmed bool
|
|
scaleSeen bool
|
|
restoreContentLanded bool
|
|
sessionSaver func(SessionState)
|
|
lastSession SessionState
|
|
lastSessionSave time.Time
|
|
}
|
|
|
|
// 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.LayoutFeedback),
|
|
resultChan: make(chan ResultEvent),
|
|
searchQueryChan: make(chan string),
|
|
findQueryChan: make(chan string),
|
|
openFileChan: make(chan string),
|
|
retryChan: make(chan string, 1), // Buffered channel
|
|
autosaveChan: make(chan struct{}),
|
|
inspectChan: make(chan *inspectReq),
|
|
writeInFlight: make(map[string]int),
|
|
savePending: make(map[string]bool),
|
|
retryScheduled: make(map[string]time.Time),
|
|
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.LayoutFeedback {
|
|
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
|
|
}
|
|
|
|
// FindQueryChan returns the in-file search query channel. The main goroutine
|
|
// forwards the "find_bar" widget's text here when it changes (see
|
|
// search.go).
|
|
func (l *Logic) FindQueryChan() chan<- string {
|
|
return l.findQueryChan
|
|
}
|
|
|
|
// ClipboardSetChan returns the channel the logic goroutine uses to request a
|
|
// system clipboard write; the main goroutine executes the Gio clipboard op.
|
|
func (l *Logic) ClipboardSetChan() <-chan string {
|
|
return l.state.clipboardSetChan
|
|
}
|
|
|
|
// PasteReqChan returns the channel the logic goroutine uses to request the
|
|
// system clipboard content for paste.
|
|
func (l *Logic) PasteReqChan() <-chan struct{} {
|
|
return l.state.pasteReqChan
|
|
}
|
|
|
|
// PasteChan delivers system clipboard content to the logic goroutine for
|
|
// paste (tests use it to inject clipboard text without a main loop).
|
|
func (l *Logic) PasteChan() chan<- string {
|
|
return l.state.pasteChan
|
|
}
|
|
|
|
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
|
|
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
|
|
|
|
// Relaunch restoration (spec §7): re-open the last file if a session
|
|
// was handed in before Run (BeginRestore). The browser index is still
|
|
// built: the back button must land on a populated browser.
|
|
if l.session.File != "" {
|
|
l.restoreFile = l.session.File
|
|
l.openFile(l.session.File)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-l.done:
|
|
// Settle in-flight file writes before exiting: the post-exit
|
|
// synchronous FlushAll and workerPool.Stop must not race a
|
|
// straggling worker write, whose rename could otherwise land after
|
|
// the final flush and promote a stale snapshot.
|
|
l.drainWrites()
|
|
return
|
|
case update := <-l.configChan:
|
|
update.apply(l.state)
|
|
if _, ok := update.(ScaleEvent); ok {
|
|
l.scaleSeen = true
|
|
}
|
|
l.emitFrame()
|
|
case fb := <-l.layoutChan:
|
|
// Store the full GlyphLayout on editor state.
|
|
// Derive LastLineY from it for scroll clamping.
|
|
layout := fb.GlyphLayout
|
|
l.state.Editor.GlyphLayout = layout
|
|
var derivedLastLineY ui.Dp
|
|
if len(layout.Y) > 0 {
|
|
derivedLastLineY = layout.Y[len(layout.Y)-1]
|
|
}
|
|
// Wrap-count correction (see WrapIndex): the layout describes the
|
|
// window it was shaped for (fb.WindowStartLine); apply its visual
|
|
// line counts to those lines, but only if no edit has shifted the
|
|
// lines since shaping (fb.EditSeq correlates with the content).
|
|
if fb.EditSeq == l.state.Editor.EditSeq {
|
|
l.state.applyWrapCounts(fb)
|
|
// Search settle (see EditorState.findSettle): the shaping above
|
|
// may have corrected the wrap counts around a find-jumped
|
|
// viewport; re-scroll while the correction still matters.
|
|
if l.state.Editor.findSettle() {
|
|
l.emitFrame()
|
|
}
|
|
}
|
|
if derivedLastLineY != l.state.LastLineY {
|
|
l.state.LastLineY = derivedLastLineY
|
|
l.emitFrame()
|
|
}
|
|
case events := <-l.inputChan:
|
|
for _, evt := range events {
|
|
evt.Handler(evt.Data)
|
|
}
|
|
l.emitFrame()
|
|
case query := <-l.searchQueryChan:
|
|
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 q := <-l.findQueryChan:
|
|
l.state.Editor.findSetQuery(q)
|
|
l.emitFrame()
|
|
case path := <-l.openFileChan:
|
|
l.openFile(path)
|
|
case filename := <-l.retryChan:
|
|
log.Printf("Logic: Retrying save for %s", filename)
|
|
delete(l.retryScheduled, filename)
|
|
l.requestSave(filename)
|
|
case <-l.autosaveChan:
|
|
// Auto-save debounce tick. The timer goroutine only sent a token;
|
|
// the owner snapshots content and dispatches the write.
|
|
l.saveTimer = nil
|
|
l.requestSave(l.state.Editor.Filename)
|
|
case p := <-l.state.pasteChan:
|
|
// Clipboard content arrived from the main goroutine: insert it
|
|
// (replacing any live selection, per the selection-aware edit rule).
|
|
HandlePaste(p)
|
|
l.emitFrame()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// openFile starts loading path in the editor (a browser row tap or the
|
|
// relaunch restore). Any open of a file that is not the in-flight restore
|
|
// cancels the restore: its late stat/read results must not re-apply the
|
|
// snapshot's positions to a different file. Must be called on the logic
|
|
// goroutine.
|
|
func (l *Logic) openFile(path string) {
|
|
if l.restoreFile != "" && l.restoreFile != path {
|
|
l.abortRestore() // a different open cancels the in-flight one
|
|
}
|
|
// Discard find results for the previous file; the query is kept
|
|
// (see EditorState.findReset) and re-scanned against the new file.
|
|
TheState.Editor.findReset()
|
|
// 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))
|
|
}
|
|
|
|
// 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() {
|
|
// Relaunch snapshot (spec §7): persist when the state has changed and
|
|
// the rate limit elapsed (tiny JSON file, see session.go).
|
|
l.saveSessionIfChanged()
|
|
elems := l.state.layout(l.browserManager)
|
|
// Relaunch restore (spec §7): land the armed restore scroll AFTER the
|
|
// layout pass above (it refreshed MaxScroll for the current viewport and
|
|
// one-way-clamps any offset set against an earlier, smaller one). The
|
|
// guard defers the application until the viewport is trustworthy — on
|
|
// device the first ScaleEvent can precede the size ConfigEvent. The
|
|
// re-layout makes this frame carry the restored viewport. The restore is
|
|
// complete once the scroll lands; drop restoreFile so a later open of
|
|
// this same file is treated as a fresh one.
|
|
if l.restoreScrollArmed && l.maybeApplyRestoreScroll() {
|
|
l.restoreFile = ""
|
|
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)
|
|
}
|
|
f := l.frameOf(elems)
|
|
select {
|
|
case l.frameChan <- f:
|
|
default:
|
|
// Main has not consumed the previous frame yet. Frames are snapshots
|
|
// of the latest state, so the newest is always the most valuable:
|
|
// replace the unread one instead of dropping this one. A dropped
|
|
// final frame would never be re-emitted (emission is event-driven),
|
|
// leaving the consumer one state behind until the next event. Still
|
|
// non-blocking: a drain from a buffer whose consumer is gone (e.g.
|
|
// the post-done write drain) still just removes the unread frame.
|
|
select {
|
|
case <-l.frameChan:
|
|
default:
|
|
return // consumer gone; nothing to replace into
|
|
}
|
|
select {
|
|
case l.frameChan <- f:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// EnableDebugCmdPoll starts a background poller (debug-only) that watches
|
|
// <dir>/cmd for a one-shot debug command (see applyDebugCmd) 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: "open <path>" (any page), and, on the editor page,
|
|
// "top", "bottom", "frac <0..1>", "dp <int>". Must be called on the logic
|
|
// goroutine.
|
|
func (l *Logic) applyDebugCmd(cmd string) {
|
|
s := l.state
|
|
fields := strings.Fields(cmd)
|
|
if len(fields) == 0 {
|
|
return
|
|
}
|
|
// "open <path>" works from any page: it opens the file and switches to
|
|
// the editor. The pre-release frame profile (scripts/profile_emulator.sh)
|
|
// uses it to open a known scrollable file deterministically instead of
|
|
// pixel-tapping the browser list.
|
|
if fields[0] == "open" {
|
|
if len(fields) < 2 {
|
|
log.Printf("DebugCmd: open needs a path")
|
|
return
|
|
}
|
|
OpenFile(fields[1])
|
|
l.emitFrame() // OpenFile only mutates state (the tap path emits via its handler)
|
|
return
|
|
}
|
|
if s.page != EditorPage {
|
|
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
|
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 {
|
|
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)
|
|
}
|
|
// Relaunch restoration (spec §7): the content is in memory, so
|
|
// land the snapshot's cursor/selection (clamped to the file)
|
|
// and re-scan the restored find query. Path-guarded: a read
|
|
// result for a file the user has since replaced must not apply
|
|
// the snapshot to the replacement's buffer.
|
|
if res.FilePath == l.state.Editor.Filename && l.restoreFile == res.FilePath {
|
|
l.applyRestorePositions(len(content))
|
|
l.restoreContentLanded = true
|
|
// The snapshot has landed: resume session saving. restoreFile
|
|
// and the armed scroll stay until the scroll itself lands in
|
|
// the emitFrame hook (which needs this content for a real
|
|
// MaxScroll), so a different open in between still aborts via
|
|
// openFile's restoreFile guard.
|
|
// Re-scan the restored query only when the bar was open:
|
|
// with the bar closed, the result would be dropped (the apply
|
|
// gate requires Visible) and Scanning would stay stuck true;
|
|
// findShow re-scans on the next open instead.
|
|
if f := &l.state.Editor.Find; f.Visible && f.Query != "" && !f.Scanning {
|
|
l.state.Editor.findDispatchScan()
|
|
}
|
|
// No armed scroll: the snapshot has fully landed with the
|
|
// content; lift the save suppression now (the emitFrame
|
|
// hook does this when a scroll does land).
|
|
if !l.restoreScrollArmed {
|
|
l.session = SessionState{}
|
|
}
|
|
}
|
|
}
|
|
} else if res.FilePath == l.restoreFile {
|
|
// The restored file's content could not be read: drop the
|
|
// restore (the editor keeps the empty file view it has).
|
|
l.abortRestore()
|
|
}
|
|
} 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
|
|
}
|
|
} else {
|
|
log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, res.Error)
|
|
}
|
|
}
|
|
} else if res.TaskType == pool.TypeStatFile {
|
|
if res.Success {
|
|
if stat, ok := res.Data.(*pool.FileStat); ok {
|
|
// Relaunch restoration (spec §7): the file exists, so land
|
|
// the snapshot's find state now (the cursor/selection land
|
|
// with the content, the query re-scans against the new file).
|
|
if res.FilePath == l.restoreFile && l.restoreFile != "" {
|
|
f := &l.state.Editor.Find
|
|
f.Query = l.session.FindQuery
|
|
f.Visible = l.session.FindVisible
|
|
f.RestoreMatch = l.session.FindCurByte
|
|
f.Restoring = l.session.FindVisible
|
|
}
|
|
// 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 {
|
|
if l.restoreFile != "" {
|
|
l.abortRestore() // no content load follows; nothing lands
|
|
}
|
|
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.FilePath == l.restoreFile && l.restoreFile != "" {
|
|
// The restored file is gone (deleted/moved since the last
|
|
// session): fall back to the browser instead of showing an
|
|
// empty editor.
|
|
l.abandonRestore()
|
|
}
|
|
} else if res.TaskType == pool.TypeBuildLineIndex {
|
|
if res.Success {
|
|
if idx, ok := res.Data.(*types.LineIndex); ok {
|
|
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
|
cb.LineIndex = idx
|
|
// The wrap index is created with the line index: same line
|
|
// set, same lifecycle. Counts start at the all-ones
|
|
// estimate and are corrected by shaped frames.
|
|
cb.WrapIndex = NewWrapIndex(idx.LineCount())
|
|
}
|
|
}
|
|
}
|
|
} else if res.TaskType == pool.TypeWriteFile {
|
|
f := res.FilePath
|
|
wrote, tracked := l.writeInFlight[f]
|
|
delete(l.writeInFlight, f)
|
|
pending := l.savePending[f]
|
|
delete(l.savePending, f)
|
|
if res.Success {
|
|
if tracked {
|
|
// Record the version whose content was actually written (the
|
|
// snapshot version), not the version now: edits made while the
|
|
// write was in flight sit ahead of it and trigger the re-issue
|
|
// below.
|
|
l.state.Editor.lastWriteVersion[f] = wrote
|
|
}
|
|
l.state.Editor.SetWriteFailed(f, false)
|
|
// Latest-state-wins re-issue: a save was deferred while this write
|
|
// was in flight, or an edit arrived during it — write the newer
|
|
// content now (only the active file is reconstructable).
|
|
if f == l.state.Editor.Filename && (pending || l.state.Editor.fileVersion[f] > l.state.Editor.lastWriteVersion[f]) {
|
|
l.requestSave(f)
|
|
}
|
|
} else {
|
|
log.Printf("Auto-save failed for %s: %v", f, res.Error)
|
|
l.state.Editor.SetWriteFailed(f, true)
|
|
|
|
// Trigger retry logic: exponential backoff
|
|
attempts := l.state.Editor.IncrementRetryAttempts(f)
|
|
// 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
|
|
}
|
|
l.retryScheduled[f] = time.Now().Add(delay)
|
|
filename := f
|
|
log.Printf("Scheduling retry for %s in %v (attempt %d)", filename, delay, attempts)
|
|
time.AfterFunc(delay, func() {
|
|
log.Printf("Firing retry for %s", filename)
|
|
select {
|
|
case l.retryChan <- filename:
|
|
default:
|
|
// A token for the same file is already queued; it will
|
|
// re-snapshot the latest content when it fires. Dropping
|
|
// keeps the timer goroutine from ever blocking.
|
|
log.Printf("Retry token for %s dropped: one already queued", filename)
|
|
}
|
|
})
|
|
}
|
|
} else if res.TaskType == pool.TypeSearch {
|
|
if res.Success {
|
|
l.state.Editor.applySearchResult(res)
|
|
}
|
|
}
|
|
l.emitFrame()
|
|
}
|
|
|
|
// 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 {
|
|
if _, inflight := l.writeInFlight[filename]; inflight {
|
|
// A worker is already writing this file and we cannot block the
|
|
// owner (we are it): defer. The write's result handler re-issues
|
|
// a fresh write while the file is still dirty, so the disk ends
|
|
// up with the latest content.
|
|
l.savePending[filename] = true
|
|
continue
|
|
}
|
|
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]
|
|
}
|
|
}
|
|
}
|
|
|
|
// requestSave is the single entry point for "persist the active file now"
|
|
// (autosave tick, retry token, deferred re-issue). It implements the
|
|
// per-file write protocol:
|
|
//
|
|
// - at most one write per file is in flight at any time. The worker pool is
|
|
// shared and the on-disk staging file is per-file, so two concurrent
|
|
// writes for the same file would interleave on the staging file and could
|
|
// rename a byte-mixture into place;
|
|
// - each write carries the file version at the moment its content was
|
|
// snapshotted (writeInFlight[f]); on success exactly that version is
|
|
// recorded as written, so any edit that arrived while the write was in
|
|
// flight leaves the file dirty and the result handler re-issues a fresh
|
|
// write with the newer content;
|
|
// - a save requested while a write is in flight is deferred (savePending)
|
|
// and re-issued by the result handler, so "last rename wins" always
|
|
// coincides with "newest snapshot wins".
|
|
//
|
|
// Must be called on the owner goroutine.
|
|
func (l *Logic) requestSave(filename string) {
|
|
if filename == "" || filename != l.state.Editor.Filename {
|
|
// Only the active file's content lives in memory (its ChunkedBuffer);
|
|
// a save for any previously opened file cannot be reconstructed.
|
|
return
|
|
}
|
|
if _, inflight := l.writeInFlight[filename]; inflight {
|
|
l.savePending[filename] = true
|
|
return
|
|
}
|
|
content, ok := l.fullContentBytes()
|
|
if !ok {
|
|
return
|
|
}
|
|
l.writeInFlight[filename] = l.state.Editor.fileVersion[filename]
|
|
l.workerPool.DispatchNonBlocking(pool.NewWriteFileTask(filename, content, l.mockFS))
|
|
}
|
|
|
|
const (
|
|
// writeDrainTimeout bounds the post-done wait for in-flight writes so a
|
|
// pathologically failing write can never hang shutdown.
|
|
writeDrainTimeout = 5 * time.Second
|
|
// writeDrainTick is the idle poll interval of the drain loop.
|
|
writeDrainTick = 20 * time.Millisecond
|
|
)
|
|
|
|
// drainWrites waits for in-flight file writes (and armed retries) to settle
|
|
// after the done signal, so that Shutdown's post-exit synchronous FlushAll
|
|
// and workerPool.Stop cannot race a straggling worker write. The write
|
|
// protocol may re-issue follow-up writes while draining; the deadline bounds
|
|
// the wait. Must be called on the owner goroutine.
|
|
func (l *Logic) drainWrites() {
|
|
if len(l.writeInFlight) == 0 && len(l.retryScheduled) == 0 {
|
|
return
|
|
}
|
|
log.Printf("Logic: draining %d in-flight write(s) before exit", len(l.writeInFlight))
|
|
deadline := time.Now().Add(writeDrainTimeout)
|
|
for {
|
|
if len(l.writeInFlight) == 0 && len(l.retryScheduled) == 0 {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
log.Printf("Logic: write drain timed out with %d in flight; exiting (per-write unique temp files keep every rename a complete snapshot)", len(l.writeInFlight))
|
|
return
|
|
}
|
|
select {
|
|
case res := <-l.workerPool.ResultChan():
|
|
l.handleWorkerResult(res)
|
|
case filename := <-l.retryChan:
|
|
delete(l.retryScheduled, filename)
|
|
l.requestSave(filename)
|
|
case <-l.autosaveChan:
|
|
l.saveTimer = nil
|
|
l.requestSave(l.state.Editor.Filename)
|
|
case <-time.After(writeDrainTick):
|
|
// No events: re-check settle and deadline.
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
// Final relaunch snapshot (spec §7): after exit the caller owns the
|
|
// state single-threaded, like FlushAll above, so one unconditional
|
|
// save makes the file exact for the next launch.
|
|
if l.sessionSaver != nil {
|
|
l.sessionSaver(l.SnapshotSession())
|
|
}
|
|
l.workerPool.Stop()
|
|
}
|