When opened with Ctrl-K, the palette now prepends entries for whatever is currently focused: - Focused scratchpad: Delete / Rename (inline form) / Edit (fire-and- forget zed launch with stdio detached so the TUI is not suspended). - Focused agent: Rename (inline form) / Close. - Focused process: Rename / Delete (drops the entry; SIGKILL if alive) / Stop (SIGTERM, keep entry) / Restart (same argv). The rename UX is a single-field inline form that mirrors the existing spawn-process form, so the modal-input contract is unchanged. scratchpad.Store grows Delete / Rename / Path so the palette can act on a pad file by name. focusedPad is plumbed onto uiState ahead of the scratchpad-focus UI work; until that lands it stays empty and the scratchpad-context entries simply never surface. Tested with palette_context_test.go and a new rename_process_via_palette harness scenario.
208 lines
5.3 KiB
Go
208 lines
5.3 KiB
Go
// Package scratchpad manages the project-scoped markdown files described
|
|
// in SPEC §3. Files live under
|
|
// $XDG_DATA_HOME/patterm/projects/<project-key>/scratchpads/. Last-write-
|
|
// wins with a revision token (SPEC §14).
|
|
package scratchpad
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Store is the per-project scratchpad directory.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
dir string
|
|
}
|
|
|
|
// Open returns a Store rooted at the SPEC §3 path for projectKey.
|
|
func Open(projectKey string) (*Store, error) {
|
|
base, err := DataDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir := filepath.Join(base, "projects", projectKey, "scratchpads")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err)
|
|
}
|
|
return &Store{dir: dir}, nil
|
|
}
|
|
|
|
// DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback.
|
|
func DataDir() (string, error) {
|
|
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
|
|
return filepath.Join(h, "patterm"), nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".local", "share", "patterm"), nil
|
|
}
|
|
|
|
// Entry is what List returns; SPEC §7 `scratchpad_list` shape.
|
|
type Entry struct {
|
|
Name string
|
|
Size int64
|
|
ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later
|
|
}
|
|
|
|
func (s *Store) Dir() string { return s.dir }
|
|
|
|
func (s *Store) List() ([]Entry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Entry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, Entry{
|
|
Name: e.Name(),
|
|
Size: info.Size(),
|
|
ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) Read(name string) (content string, revision string, err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, err := s.safePath(name)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return string(b), revisionOf(b), nil
|
|
}
|
|
|
|
// RevisionMismatchError is returned by Write when expectedRevision
|
|
// doesn't match the on-disk revision. The MCP scratchpad_write tool
|
|
// surfaces CurrentRevision as `current_revision` in the response so
|
|
// the caller can re-read and merge (SPEC §7 / §14).
|
|
type RevisionMismatchError struct {
|
|
CurrentRevision string
|
|
}
|
|
|
|
func (e *RevisionMismatchError) Error() string {
|
|
return "scratchpad: revision mismatch (current=" + e.CurrentRevision + ")"
|
|
}
|
|
|
|
// Write replaces the file's contents. expectedRevision, if non-empty,
|
|
// must match the current revision or the write is rejected with a
|
|
// *RevisionMismatchError (SPEC §14 last-write-wins-with-token).
|
|
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, err := s.safePath(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if expectedRevision != "" {
|
|
if cur, err := os.ReadFile(p); err == nil {
|
|
curRev := revisionOf(cur)
|
|
if curRev != expectedRevision {
|
|
return "", &RevisionMismatchError{CurrentRevision: curRev}
|
|
}
|
|
}
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
return revisionOf([]byte(content)), nil
|
|
}
|
|
|
|
func (s *Store) Append(name, content string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, err := s.safePath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = f.WriteString(content)
|
|
return err
|
|
}
|
|
|
|
// Delete removes the scratchpad file. Missing files are reported as
|
|
// errors; callers that want "delete if exists" can ignore os.ErrNotExist.
|
|
func (s *Store) Delete(name string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, err := s.safePath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(p)
|
|
}
|
|
|
|
// Rename moves a scratchpad file to a new name within the same project
|
|
// directory. Returns os.ErrExist if newName already exists; the caller
|
|
// is expected to surface that to the user rather than clobber.
|
|
func (s *Store) Rename(oldName, newName string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
src, err := s.safePath(oldName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dst, err := s.safePath(newName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if src == dst {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return fmt.Errorf("scratchpad: %q already exists", newName)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return os.Rename(src, dst)
|
|
}
|
|
|
|
// Path returns the absolute path of a scratchpad file. The file does
|
|
// not need to exist; callers like "Edit scratchpad" rely on this to
|
|
// hand the path to an external editor.
|
|
func (s *Store) Path(name string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.safePath(name)
|
|
}
|
|
|
|
func (s *Store) safePath(name string) (string, error) {
|
|
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
|
|
return "", errors.New("scratchpad: invalid name")
|
|
}
|
|
return filepath.Join(s.dir, name), nil
|
|
}
|
|
|
|
func revisionOf(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:6])
|
|
}
|