Pad/internal/io/pool/task.go
Greg Pomerantz 7240b62a61 WIP baseline: Termux open-file bridge + word-wrap-aware viewport/scroll
- JNI: open_file_in_termux via ACTION_SEND intent (text/plain + file:// uri),
  global context ref kept from registerFragment
- impl_android.go: OpenFile(path) attaches current thread if needed
- NewLogic takes openfunc; State.open + ui.OpenFile now func(string)
- ChunkedBuffer.VisibleByteRange: word-wrap path using
  GlyphLayout.VisualLineStarts (+byteOffset, lineHeight, visual index param)
- GlyphLayout gains LineHeight; drawWrappedText records VisualLineStarts
- WordWrap default true; State.ByteOffset tracks first visible line
- types: VisualLineIndex
- scroll_fix_test.go (new)
- debug prints left in place (WIP; cleanup in later phase)
2026-08-16 00:31:26 -04:00

652 lines
19 KiB
Go

package pool
import (
"context"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"pad/internal/io/pool/types"
)
// taskIDCounter provides unique IDs for tasks.
var taskIDCounter atomic.Int64
// Task defines the interface for a worker task.
type Task interface {
Execute() Result
Priority() Priority
TaskType() TaskType
TaskID() string
DirPath() string
Context() context.Context
Timeout() time.Duration
Cancel()
}
// Priority defines task priority.
type Priority int
const (
LowPriority Priority = iota
MediumPriority
HighPriority
)
func (p Priority) String() string {
switch p {
case LowPriority:
return "low"
case MediumPriority:
return "medium"
case HighPriority:
return "high"
default:
return "unknown"
}
}
// TaskType defines the type of task.
type TaskType int
const (
TypeUnknown TaskType = iota
TypeReadFile
TypeReadChunk
TypeStatFile
TypeBuildLineIndex
TypeWriteFile
TypeBuildIndex
// Browser task types
TypeReadDir
TypeLoadIndex
TypeLoadPages
TypeStatDir
// Cache task types
TypeWriteCache
TypeReadCache
TypeInvalidate
// State persistence types
TypeSaveState
TypeSaveUndo
)
func (t TaskType) String() string {
switch t {
case TypeReadFile:
return "read_file"
case TypeReadChunk:
return "read_chunk"
case TypeStatFile:
return "stat_file"
case TypeBuildLineIndex:
return "build_line_index"
case TypeWriteFile:
return "write_file"
case TypeBuildIndex:
return "build_index"
case TypeReadDir:
return "read_dir"
case TypeLoadIndex:
return "load_index"
case TypeLoadPages:
return "load_pages"
case TypeStatDir:
return "stat_dir"
case TypeWriteCache:
return "write_cache"
case TypeReadCache:
return "read_cache"
case TypeInvalidate:
return "invalidate_cache"
case TypeSaveState:
return "save_state"
case TypeSaveUndo:
return "save_undo"
default:
return "unknown"
}
}
// --- Specific Task Implementations ---
// ReadChunkTask reads a specific chunk of a file.
type ReadChunkTask struct {
taskID string
Path string
ChunkIdx int
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadChunkTask creates a new ReadChunkTask.
func NewReadChunkTask(path string, chunkIdx int, fs FileSystem) *ReadChunkTask {
ctx, cancel := context.WithCancel(context.Background())
return &ReadChunkTask{
taskID: fmt.Sprintf("readchunk-%d-%s", chunkIdx, filepath.Base(path)),
Path: path,
ChunkIdx: chunkIdx,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadChunkTask) Execute() Result {
// Use ReadFileAt to read only the specific chunk range (not the entire file)
chunkSize := 64 * 1024 // Must match the chunk size used in ChunkedBuffer
start := t.ChunkIdx * chunkSize
chunk, err := t.FS.ReadFileAt(t.Path, start, chunkSize)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read chunk %d: %w", t.ChunkIdx, err)}
}
return Result{
TaskID: t.taskID,
TaskType: TypeReadChunk,
FilePath: t.Path,
Success: true,
Data: chunk,
ChunkIdx: t.ChunkIdx, // Explicitly pass the chunk index
Timestamp: time.Now(),
}
}
func (t *ReadChunkTask) Priority() Priority { return HighPriority }
func (t *ReadChunkTask) TaskType() TaskType { return TypeReadChunk }
func (t *ReadChunkTask) TaskID() string { return t.taskID }
func (t *ReadChunkTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadChunkTask) Context() context.Context { return t.ctx }
func (t *ReadChunkTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadChunkTask) Cancel() { if t.cancel != nil { t.cancel() } }
// StatFileTask retrieves file metadata (like size and modification time).
// The plan indicates this replaces the full-file ReadFileTask for opening.
type StatFileTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewStatFileTask creates a new StatFileTask.
func NewStatFileTask(path string, fs FileSystem) *StatFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &StatFileTask{
taskID: fmt.Sprintf("statfile-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *StatFileTask) Execute() Result {
// In the current plan, this reads the full file content to get size.
// A more optimized version for large files would use os.Stat or similar,
// which returns size directly without reading content.
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for stat: %w", err)}
}
// We also need mtime for invalidation, but FileInfo might not be directly returned as Data.
// For now, just returning size.
return Result{
TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: true,
Data: &FileStat{Path: t.Path, Size: int64(len(content))},
}
}
func (t *StatFileTask) Priority() Priority { return MediumPriority }
func (t *StatFileTask) TaskType() TaskType { return TypeStatFile }
func (t *StatFileTask) TaskID() string { return t.taskID }
func (t *StatFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *StatFileTask) Context() context.Context { return t.ctx }
func (t *StatFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *StatFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
// FileStat holds file metadata.
type FileStat struct {
Path string
Size int64
// MTime time.Time // Add this if FileSystem.Stat returns it and we need it
}
// BuildLineIndexTask builds the line index for a file.
type BuildLineIndexTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewBuildLineIndexTask creates a new BuildLineIndexTask.
func NewBuildLineIndexTask(path string, fs FileSystem) *BuildLineIndexTask {
ctx, cancel := context.WithCancel(context.Background())
return &BuildLineIndexTask{
taskID: fmt.Sprintf("buildindex-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *BuildLineIndexTask) Execute() Result {
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for index build: %w", err)}
}
offsets := []int32{0} // Line 0 starts at byte offset 0
for i := 0; i < len(content); i++ {
if content[i] == '\n' {
offsets = append(offsets, int32(i+1))
}
}
lineIndex := types.NewLineIndex(offsets, 0, int64(len(content)))
return Result{
TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: true,
Data: lineIndex,
}
}
func (t *BuildLineIndexTask) Priority() Priority { return LowPriority }
func (t *BuildLineIndexTask) TaskType() TaskType { return TypeBuildLineIndex }
func (t *BuildLineIndexTask) TaskID() string { return t.taskID }
func (t *BuildLineIndexTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *BuildLineIndexTask) Context() context.Context { return t.ctx }
func (t *BuildLineIndexTask) Timeout() time.Duration { return 30 * time.Second }
func (t *BuildLineIndexTask) Cancel() { if t.cancel != nil { t.cancel() } }
// BuildIndexTask builds the directory index for the browser.
type BuildIndexTask struct {
taskID string
Dir string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewBuildIndexTask creates a new BuildIndexTask.
func NewBuildIndexTask(dir string, fs FileSystem) *BuildIndexTask {
ctx, cancel := context.WithCancel(context.Background())
return &BuildIndexTask{
taskID: fmt.Sprintf("buildindex-%s", filepath.Base(dir)),
Dir: dir,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *BuildIndexTask) Execute() Result {
entries, err := t.FS.ReadDir(t.Dir)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: true, Data: entries}
}
func (t *BuildIndexTask) Priority() Priority { return HighPriority }
func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex }
func (t *BuildIndexTask) TaskID() string { return t.taskID }
func (t *BuildIndexTask) DirPath() string { return t.Dir }
func (t *BuildIndexTask) Context() context.Context { return t.ctx }
func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second }
func (t *BuildIndexTask) Cancel() { if t.cancel != nil { t.cancel() } }
// LoadPagesTask loads specific pages from a directory index.
type LoadPagesTask struct {
taskID string
Dir string
PageIdxs []int
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewLoadPagesTask creates a new LoadPagesTask.
func NewLoadPagesTask(dir string, pageIdxs []int, fs FileSystem) *LoadPagesTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &LoadPagesTask{
taskID: fmt.Sprintf("loadpages-%s-%d", filepath.Base(dir), id),
Dir: dir,
PageIdxs: pageIdxs,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *LoadPagesTask) Execute() Result {
// In a real implementation, this would read the actual entry data for the pages.
// For now, just return the page indices as success — the browser manager
// will load the actual data from the SortIndex.
return Result{TaskID: t.taskID, TaskType: TypeLoadPages, Success: true, Data: t.PageIdxs}
}
func (t *LoadPagesTask) Priority() Priority { return HighPriority }
func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages }
func (t *LoadPagesTask) TaskID() string { return t.taskID }
func (t *LoadPagesTask) DirPath() string { return t.Dir }
func (t *LoadPagesTask) Context() context.Context { return t.ctx }
func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second }
func (t *LoadPagesTask) Cancel() { if t.cancel != nil { t.cancel() } }
// ReadDirTask reads directory entries.
type ReadDirTask struct {
taskID string
Dir string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadDirTask creates a new ReadDirTask.
func NewReadDirTask(dir string, fs FileSystem) *ReadDirTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &ReadDirTask{
taskID: fmt.Sprintf("readdir-%s-%d", filepath.Base(dir), id),
Dir: dir,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadDirTask) Execute() Result {
entries, err := t.FS.ReadDir(t.Dir)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: true, Data: entries}
}
func (t *ReadDirTask) Priority() Priority { return HighPriority }
func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir }
func (t *ReadDirTask) TaskID() string { return t.taskID }
func (t *ReadDirTask) DirPath() string { return t.Dir }
func (t *ReadDirTask) Context() context.Context { return t.ctx }
func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadDirTask) Cancel() { if t.cancel != nil { t.cancel() } }
// SaveStateTask persists application state.
type SaveStateTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewSaveStateTask creates a new SaveStateTask.
func NewSaveStateTask(path string, content []byte, fs FileSystem) *SaveStateTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &SaveStateTask{
taskID: fmt.Sprintf("savestate-%s-%d", filepath.Base(path), id),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *SaveStateTask) Execute() Result {
err := t.FS.WriteFileAtomic(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: false, Error: fmt.Errorf("failed to save state: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: true}
}
func (t *SaveStateTask) Priority() Priority { return LowPriority }
func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState }
func (t *SaveStateTask) TaskID() string { return t.taskID }
func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveStateTask) Context() context.Context { return t.ctx }
func (t *SaveStateTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveStateTask) Cancel() { if t.cancel != nil { t.cancel() } }
// SaveUndoTask persists undo stack.
type SaveUndoTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewSaveUndoTask creates a new SaveUndoTask.
func NewSaveUndoTask(path string, content []byte, fs FileSystem) *SaveUndoTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &SaveUndoTask{
taskID: fmt.Sprintf("saveundo-%s-%d", filepath.Base(path), id),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *SaveUndoTask) Execute() Result {
err := t.FS.WriteFileAtomic(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: false, Error: fmt.Errorf("failed to save undo: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: true}
}
func (t *SaveUndoTask) Priority() Priority { return LowPriority }
func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo }
func (t *SaveUndoTask) TaskID() string { return t.taskID }
func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveUndoTask) Context() context.Context { return t.ctx }
func (t *SaveUndoTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveUndoTask) Cancel() { if t.cancel != nil { t.cancel() } }
// ReadFileTask reads the full content of a file.
// Used as a fallback for small files or initial load before chunking is set up.
type ReadFileTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadFileTask creates a new ReadFileTask.
func NewReadFileTask(path string, fs FileSystem) *ReadFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &ReadFileTask{
taskID: fmt.Sprintf("readfile-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadFileTask) Execute() Result {
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: true, Data: content}
}
func (t *ReadFileTask) Priority() Priority { return HighPriority }
func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile }
func (t *ReadFileTask) TaskID() string { return t.taskID }
func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadFileTask) Context() context.Context { return t.ctx }
func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
// WriteFileTask writes content to a file.
type WriteFileTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewWriteFileTask creates a new WriteFileTask.
func NewWriteFileTask(path string, content []byte, fs FileSystem) *WriteFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &WriteFileTask{
taskID: fmt.Sprintf("writefile-%s", filepath.Base(path)),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *WriteFileTask) Execute() Result {
err := t.FS.WriteFile(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to write file: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: true}
}
func (t *WriteFileTask) Priority() Priority { return LowPriority }
func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile }
func (t *WriteFileTask) TaskID() string { return t.taskID }
func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *WriteFileTask) Context() context.Context { return t.ctx }
func (t *WriteFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *WriteFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
// --- Mock File System (for testing/development) ---
// MockFS implements the pool.FileSystem interface for testing.
type MockFS struct {
files map[string][]byte
}
func NewMockFS() *MockFS {
return &MockFS{
files: make(map[string][]byte),
}
}
func (m *MockFS) ReadFile(path string) ([]byte, error) {
content, ok := m.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
return content, nil
}
func (m *MockFS) ReadFileAt(path string, offset, size int) ([]byte, error) {
content, ok := m.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
if offset >= len(content) {
return []byte{}, nil
}
end := offset + size
if end > len(content) {
end = len(content)
}
result := make([]byte, len(content[offset:end]))
copy(result, content[offset:end])
return result, nil
}
func (m *MockFS) WriteFile(path string, content []byte) error {
m.files[path] = content
return nil
}
func (m *MockFS) WriteFileAtomic(path string, content []byte) error {
m.files[path] = content
return nil
}
func (m *MockFS) DeleteFile(path string) error {
delete(m.files, path)
return nil
}
func (m *MockFS) CreateDir(path string) error {
return nil
}
func (m *MockFS) DirExists(path string) bool {
_, ok := m.files[path]
return ok
}
func (m *MockFS) FileExists(path string) bool {
_, ok := m.files[path]
return ok
}
// mockFileInfo implements io.FileInfo for MockFS.
type mockFileInfo struct {
name string
size int64
mode uint32
modTime time.Time
}
func (f *mockFileInfo) Name() string { return f.name }
func (f *mockFileInfo) Size() int64 { return f.size }
func (f *mockFileInfo) Mode() os.FileMode { return os.FileMode(f.mode) }
func (f *mockFileInfo) ModTime() time.Time { return f.modTime }
func (f *mockFileInfo) IsDir() bool { return false }
func (f *mockFileInfo) Sys() any { return nil }
// mockDirEntry implements types.DirEntry for MockFS ReadDir.
type mockDirEntry struct {
name string
isDir bool
}
func (e *mockDirEntry) Name() string { return e.name }
func (e *mockDirEntry) IsDir() bool { return e.isDir }
func (e *mockDirEntry) Info() (os.FileInfo, error) {
return &mockFileInfo{name: e.name, size: 0}, nil
}
func (m *MockFS) ReadDir(path string) ([]types.DirEntry, error) {
var entries []types.DirEntry
for p := range m.files {
if filepath.Dir(p) == path {
entries = append(entries, &mockDirEntry{name: filepath.Base(p), isDir: false})
}
}
return entries, nil
}
var _ FileSystem = (*MockFS)(nil) // Compile-time interface check