The renderer kept a mirror of the pushed IME snippet (the 'IME model') to translate commit positions, but it transiently desynced from the buffer on fling/tap sequences (observed as a few-byte mapping drift on both the x86_64 emulator and the ARM phone), corrupting text. The model string also sat on the main goroutine next to the JNI render path, where the app observed states that were impossible for Go memory (string contents changing between reads microseconds apart), pointing at corruption in the native bridge layer. Restructure along the lines of the Android InputConnection contract and Gio's own reference editor (widget/editor.go): - Commits carry absolute file runes (the pushed snippet's coordinate space) straight to the logic goroutine, which maps them to bytes against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan). Scrolling moves the window, not the buffer, so the mapping is exact mid-fling by construction — no mirror to desync. - Drift guard in HandleIMECommit: a small commit (range <= 2 runes) is always anchored at the caret the IME was last told about; if the IME reports it ending elsewhere, its snippet text is stale (a dropped restartInput, as Gboard does during flings) and its position is in the stale text's coordinates — snap the commit to the cursor, the only position it cannot drift from. - FlushIME simplifies to: push the snippet when the frame's (context+window) text differs from the last push (gioui dedupes against its own cache), force the selection re-push in the same frame. After a commit the frame text equals what the IME already holds locally, so the restart is naturally suppressed; a fling re-anchors the IME once per text change. - Remove the renderer model (adoptFrame/ModelTranslate/ ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the window-relative imeRuneToByte. Also fixed along the way (both found while chasing the corruption): - real.ReadFileAt: loop over short reads. A single ReadAt on Android FUSE can return a short read, silently truncating a chunk and shifting every byte offset after it. - logic: a late lazy-chunk result no longer clobbers a buffer that SetContent has already fully loaded. - e2e: large-file IME test (1.6 MB file, fling + commit). - app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio builds the mipmap/adaptive icon set. Verified: go vet + staticcheck, go test -race (all packages), and the emulator scenario loop (open moby excerpt, fling to mid-file, tap, type 'a', byte-compare the saved file) 75/75 clean.
1094 lines
41 KiB
Go
1094 lines
41 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)
|
|
|
|
// flushSession: one-shot request to persist the session snapshot now
|
|
// (the OS activity onStop hook, see FlushSession). Buffered 1 so the
|
|
// requester never blocks, even if an earlier flush is still queued.
|
|
flushSession chan struct{}
|
|
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
|
|
// Browser live-list (see dirwatcher.go). The primary trigger is inotify:
|
|
// browserWatcher watches the current browser directory and sends a token on
|
|
// browserEventChan when it changes (file created/removed/renamed or
|
|
// mtime-changed, incl. inbound syncs). browserRefreshTimer is a slow
|
|
// safety-net rescan that covers the rare event the FUSE inotify layer drops.
|
|
// Both are owner-goroutine-only and funnel into refreshBrowserDir.
|
|
browserEventChan chan struct{} // inotify: dirWatcher -> owner (buffered 1)
|
|
browserWatcher *dirWatcher // inotify watch on the current browser dir
|
|
browserRefreshChan chan struct{} // backstop rescan tick (timer -> owner)
|
|
browserRefreshTimer *time.Timer // backstop rescan 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
|
|
// restoreScrollLine/restoreScrollSub/restorePinDeadline implement the
|
|
// restore line-pin (see refreshRestorePin in session.go): the
|
|
// line-derived scroll offset is only consistent with the all-estimate
|
|
// WrapIndex, so while relaunch restore is settling, every wrap-count
|
|
// correction landing below the pinned line shifts the offset-to-line
|
|
// mapping and would drag the viewport off the restored line. Until the
|
|
// restored window itself has shaped (or a timeout, or the user or a
|
|
// search takes over), the offset is re-derived from the pinned line
|
|
// after each correction.
|
|
restoreScrollLine int
|
|
restoreScrollSub float64
|
|
restorePinDeadline time.Time
|
|
// fontPin/fontPinM implement the pinch font-pin (see
|
|
// setFontPin/refreshFontPin in session.go): the CONTENT point under the
|
|
// pinch center (glyph byte + offset from its baseline, with a
|
|
// line/fragment/sub-line fallback) and the center's region-relative Y
|
|
// (dp). While armed, every shaped layout re-derives the scroll offset to
|
|
// keep that content point under the center as the font scale — and, a
|
|
// few frames later, the rewrap — changes.
|
|
fontPin contentPin
|
|
fontPinM float64
|
|
fontPinArmed bool
|
|
fontPinDeadline time.Time
|
|
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{}),
|
|
browserEventChan: make(chan struct{}, 1),
|
|
browserRefreshChan: make(chan struct{}),
|
|
flushSession: make(chan struct{}, 1),
|
|
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()
|
|
// Close the inotify watcher on any exit. Run is the owner goroutine, so at
|
|
// return the watcher is single-threaded and safe to close here (covers both
|
|
// Shutdown and the test-harness Done+WaitForExit path). The closure reads
|
|
// browserWatcher at exit time, after emitFrame has created it if the browser
|
|
// was ever shown.
|
|
defer func() {
|
|
if l.browserWatcher != nil {
|
|
l.browserWatcher.Close()
|
|
}
|
|
}()
|
|
|
|
// Dispatch the initial directory index build on startup, routed through the
|
|
// manager so the in-flight marker is set (a later refresh of the same
|
|
// directory coalesces, and a late result is dropped once we navigate).
|
|
l.browserManager.Load(l.state.Browser.CurrentPath)
|
|
|
|
// 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 <-l.flushSession:
|
|
// Persist now, bypassing the rate limit: the activity is going
|
|
// away (recents-wipe or app switch) and the process may die
|
|
// shortly after this returns.
|
|
l.flushSessionSave()
|
|
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)
|
|
// Restore line-pin (see refreshRestorePin): a correction
|
|
// landing below the pinned line shifted the offset-to-line
|
|
// mapping, so re-derive the offset from the pinned line under
|
|
// the corrected index. The restored window's own shaping means
|
|
// its neighborhood is real and the pin can stand down.
|
|
if l.restoreScrollLine >= 0 {
|
|
if fb.WindowStartLine == l.restoreScrollLine {
|
|
l.restoreScrollLine = -1
|
|
} else {
|
|
l.refreshRestorePin()
|
|
}
|
|
}
|
|
// Pinch font-pin (see refreshFontPin): the fresh layout may
|
|
// have rewrapped the pinned line; re-derive so the pinned
|
|
// content point stays under the pinch center.
|
|
if l.fontPinArmed {
|
|
l.refreshFontPin(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 <-l.browserEventChan:
|
|
// inotify: the current browser directory changed (an external create,
|
|
// delete, rename, or mtime change). Re-read it; no-op while the
|
|
// editor is on screen (the change is picked up on the next return).
|
|
l.refreshBrowserDir()
|
|
case <-l.browserRefreshChan:
|
|
// Slow backstop rescan tick (safety net for a dropped inotify event).
|
|
// The timer goroutine only sent a token; the owner re-reads the current
|
|
// directory (bounded) and re-arms. No-op while the editor is on screen.
|
|
l.browserRefreshTimer = nil
|
|
l.refreshBrowserDir()
|
|
l.armBrowserRefresh()
|
|
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
|
|
}
|
|
l.releaseFontPin() // the pin names lines of the previous file
|
|
// 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
|
|
// New file: the previous file's IME rune-count cache and window bookkeeping
|
|
// are meaningless. Reset the anchor so the next imeRuneOffsetAt recomputes
|
|
// from scratch (the window/context fields are recomputed by buildFrame).
|
|
TheState.Editor.imeRuneCacheByte = 0
|
|
TheState.Editor.imeRuneCacheCount = 0
|
|
TheState.Editor.IMEContext = ""
|
|
TheState.Editor.IMEContextStartByte = 0
|
|
TheState.Editor.IMEOffsetRune = 0
|
|
|
|
// 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()
|
|
// While the browser is on screen, keep its directory fresh so external
|
|
// adds/removes/renames/mtime changes (incl. inbound syncs) appear live, in
|
|
// the current sort order. Point the inotify watch at the current directory
|
|
// (created on first use; re-pointed on navigation, a no-op while the path is
|
|
// unchanged) and arm the slow backstop rescan. Runs on every browser frame,
|
|
// covering startup, back-from-editor, and sort toggle.
|
|
if l.state.page == BrowserPage {
|
|
if l.browserWatcher == nil {
|
|
l.browserWatcher = newDirWatcher(l.browserEventChan)
|
|
}
|
|
l.browserWatcher.SetDir(l.state.Browser.CurrentPath)
|
|
l.armBrowserRefresh()
|
|
}
|
|
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 command from the cmd-file poller.
|
|
// Commands: "open <path>" (any page), and, on the editor page, "top",
|
|
// "bottom", "frac <0..1>", "dp <int>", "pinch <factor>" (relative app font
|
|
// scale, as the renderer's pinch probe would deliver) and "fontsize <v>"
|
|
// (absolute app font scale). 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
|
|
}
|
|
// App-local font scale (pinch zoom). Drives the full logic->frame->render
|
|
// path the same way a real pinch does (HandleFontPinch is the handler a
|
|
// FontPinchEvent carries); adb has no two-finger input, so these
|
|
// commands are the on-emulator test hook. `pinch` anchors at the CENTER
|
|
// of the editor region (where a real pinch usually starts); `fontsize`
|
|
// is an absolute top-anchored set.
|
|
if fields[0] == "pinch" || fields[0] == "fontsize" {
|
|
if s.page != EditorPage {
|
|
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
|
return
|
|
}
|
|
if len(fields) < 2 {
|
|
log.Printf("DebugCmd: %s needs a value", fields[0])
|
|
return
|
|
}
|
|
f, err := strconv.ParseFloat(fields[1], 32)
|
|
if err != nil || f <= 0 {
|
|
log.Printf("DebugCmd: bad %s value %q", fields[0], fields[1])
|
|
return
|
|
}
|
|
if fields[0] == "pinch" {
|
|
if f > 100 { // a single frame's pinch never spans this much
|
|
log.Printf("DebugCmd: pinch factor out of range: %v", f)
|
|
return
|
|
}
|
|
HandleFontPinch(ui.FontPinchEvent{
|
|
Scale: float32(f),
|
|
Center: ui.Point{
|
|
X: s.EditorRegion.X + s.EditorRegion.W/2,
|
|
Y: s.EditorRegion.Y + s.EditorRegion.H/2,
|
|
},
|
|
})
|
|
} else {
|
|
SetAppFontScale(float32(f))
|
|
}
|
|
log.Printf("DebugCmd: %q -> appFontScale=%.4f scroll=%d", cmd, s.appFontScale, int(s.ScrollOffset))
|
|
l.emitFrame()
|
|
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
|
|
}
|
|
l.releaseFontPin() // a debug scroll takes over the viewport
|
|
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{}{}
|
|
})
|
|
}
|
|
|
|
// browserBackstopInterval is how often the visible browser directory is
|
|
// re-read as a SLOW safety net while the browser page is on screen. The primary
|
|
// trigger is inotify (browserWatcher, see dirwatcher.go), which re-reads within
|
|
// ~tens of ms of a real change; this rescan only covers the rare event the FUSE
|
|
// inotify layer drops. One directory read per interval is a single ReadDir plus
|
|
// a per-entry stat; the manager's in-flight marker keeps it to one read at a
|
|
// time, and its change-detection emits no frame when nothing changed.
|
|
const browserBackstopInterval = 30 * time.Second
|
|
|
|
// refreshBrowserDir re-reads the current browser directory (bounded to one in
|
|
// flight by the manager). Triggered by either an inotify event
|
|
// (browserEventChan) or the slow backstop tick (browserRefreshChan). No-op
|
|
// while the editor is on screen — there is no list to keep fresh there.
|
|
func (l *Logic) refreshBrowserDir() {
|
|
if l.state.page != BrowserPage {
|
|
return
|
|
}
|
|
l.browserManager.Refresh(l.state.Browser.CurrentPath)
|
|
}
|
|
|
|
// armBrowserRefresh starts (or leaves running) the slow backstop rescan timer.
|
|
// Idempotent: at most one timer at a time. The timer goroutine only sends a
|
|
// token on browserRefreshChan; the owner does the re-read. The send is
|
|
// non-blocking so the timer goroutine can never block, even after the logic
|
|
// loop has exited (the token is then simply dropped). Owner-goroutine-only.
|
|
func (l *Logic) armBrowserRefresh() {
|
|
if l.browserRefreshTimer != nil {
|
|
return
|
|
}
|
|
l.browserRefreshTimer = time.AfterFunc(browserBackstopInterval, func() {
|
|
select {
|
|
case l.browserRefreshChan <- struct{}{}:
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
|
|
// handleWorkerResult processes results from the worker pool.
|
|
func (l *Logic) handleWorkerResult(res pool.Result) {
|
|
// A browser result re-emits only when it actually changed the view; a
|
|
// periodic refresh of an unchanged directory is dropped here, keeping
|
|
// emission event-driven while the browser idles. Other results always
|
|
// re-emit.
|
|
emit := true
|
|
if res.IsBrowserResult() {
|
|
emit = 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 {
|
|
// A full-file load (SetContent) may have finished while this
|
|
// lazy chunk read was in flight; its resident chunks are the
|
|
// authoritative content and must not be clobbered by a stale
|
|
// lazy result (which can also be a short read). Applying it
|
|
// would truncate/shift the chunk and move every byte offset
|
|
// after it — taps and IME commits landing at the wrong
|
|
// position (text corruption).
|
|
if !cb.fullyLoaded {
|
|
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)
|
|
}
|
|
}
|
|
if emit {
|
|
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()
|
|
}
|