Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
734 lines
24 KiB
Go
734 lines
24 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
|
|
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
|
|
}
|
|
|
|
// 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),
|
|
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
|
|
}
|
|
|
|
// 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))
|
|
|
|
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)
|
|
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)
|
|
}
|
|
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 path := <-l.openFileChan:
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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 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 <int>". 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 {
|
|
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
|
|
}
|
|
} 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 {
|
|
// 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 {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
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()
|
|
l.workerPool.Stop()
|
|
}
|