app: add loopback multi-project registry
This commit is contained in:
@@ -7,6 +7,13 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- patterm can now keep multiple local projects loaded in one loopback
|
||||
daemon core, with command-palette entries to switch the current
|
||||
client view or open another project without tearing down processes
|
||||
in the previous project.
|
||||
- The status line now shows the current project name when multiple
|
||||
projects are loaded, and the MCP startup greeting includes
|
||||
`project_key` for diagnostics and future daemon routing.
|
||||
- MCP clients can now call `scratchpad_delete` with a scratchpad name
|
||||
to remove a shared project scratchpad.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -18,7 +19,6 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/persist"
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
"github.com/hjbdev/patterm/internal/trust"
|
||||
@@ -60,27 +60,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||
logf("settings load: %v", err)
|
||||
}
|
||||
|
||||
// Ensure the per-project scratchpad dir exists so MCP and the UI
|
||||
// can read/write into it. SPEC §3.
|
||||
pads, err := scratchpad.Open(opts.ProjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: scratchpad init: %w", err)
|
||||
}
|
||||
|
||||
// Per-project trust store for command-preset trust gating (SPEC §7).
|
||||
trustStore, err := trust.Open(opts.ProjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: trust init: %w", err)
|
||||
}
|
||||
|
||||
// Per-project persisted-process store. Survives across patterm
|
||||
// restarts so user-created top-level command processes come back
|
||||
// after a relaunch.
|
||||
persistStore, err := persist.Open(opts.ProjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: persist init: %w", err)
|
||||
}
|
||||
|
||||
// In-process MCP server bound to the per-PID socket. Children that
|
||||
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
||||
// SPEC §10.
|
||||
@@ -90,48 +69,10 @@ func Run(ctx context.Context, opts Options) error {
|
||||
}
|
||||
defer mcpSrv.Close()
|
||||
|
||||
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
|
||||
defer sess.Shutdown()
|
||||
|
||||
// Debug capture: when --debug=<dir> is set, write a verbose log
|
||||
// (patterm.log), per-child raw PTY output (<id>.raw), and a
|
||||
// JSONL event stream (events.jsonl). Installed before the TUI
|
||||
// listener so the very first OnChildSpawned / OnPTYOut event
|
||||
// is captured.
|
||||
if opts.DebugDir != "" {
|
||||
dc, err := openDebugCapture(opts.DebugDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: debug capture: %w", err)
|
||||
}
|
||||
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
|
||||
sess.Subscribe(dc)
|
||||
defer dc.Close()
|
||||
logf("debug capture enabled at %s", opts.DebugDir)
|
||||
}
|
||||
// Snapshot persisted processes BEFORE attaching the store: Spawn
|
||||
// mints fresh ids, so the old records would otherwise linger
|
||||
// alongside the new ones. Drop them up front; the restore loop
|
||||
// below re-saves each entry under its new id.
|
||||
savedProcesses := persistStore.List()
|
||||
for _, e := range savedProcesses {
|
||||
_ = persistStore.Remove(e.ID)
|
||||
}
|
||||
sess.SetPersistStore(persistStore)
|
||||
|
||||
cols, rows := hostSize()
|
||||
|
||||
layout := newTerminalLayout(cols, rows)
|
||||
|
||||
// Launcher handles preset → child translation, including MCP
|
||||
// config injection for agent presets.
|
||||
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
|
||||
|
||||
// Wire the tool host into MCP. Spawns through MCP use the host
|
||||
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
|
||||
// resize them later.
|
||||
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
|
||||
mcpSrv.SetHost(host)
|
||||
|
||||
var restoreState *term.State
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
st, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
@@ -156,39 +97,41 @@ func Run(ctx context.Context, opts Options) error {
|
||||
defer metrics.close()
|
||||
}
|
||||
|
||||
// Per-session idle-detection classifier. One goroutine ticks every
|
||||
// 250ms over every live child and updates IdleState. It stops when
|
||||
// ctx is cancelled.
|
||||
go sess.runClassifier(ctx)
|
||||
|
||||
core := &headlessCore{
|
||||
projectDir: opts.ProjectDir,
|
||||
projectKey: opts.ProjectKey,
|
||||
presets: presets,
|
||||
settings: appSettings,
|
||||
pads: pads,
|
||||
trustStore: trustStore,
|
||||
persistStore: persistStore,
|
||||
mcpSrv: mcpSrv,
|
||||
sess: sess,
|
||||
launcher: launcher,
|
||||
host: host,
|
||||
registry := newProjectRegistry(presets, appSettings, mcpSrv, layout.childCols(), layout.childRows())
|
||||
project, err := registry.Open(ctx, opts.ProjectDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer registry.Shutdown()
|
||||
mcpSrv.SetHost(registry)
|
||||
|
||||
if opts.DebugDir != "" {
|
||||
dc, err := openDebugCapture(opts.DebugDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: debug capture: %w", err)
|
||||
}
|
||||
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
|
||||
project.Session.Subscribe(dc)
|
||||
defer dc.Close()
|
||||
logf("debug capture enabled at %s", opts.DebugDir)
|
||||
}
|
||||
_ = core
|
||||
|
||||
st := &uiState{
|
||||
sess: sess,
|
||||
registry: registry,
|
||||
project: project,
|
||||
sess: project.Session,
|
||||
presets: presets,
|
||||
launcher: launcher,
|
||||
pads: pads,
|
||||
launcher: project.Launcher,
|
||||
pads: project.Pads,
|
||||
chromeWake: make(chan struct{}, 1),
|
||||
trust: trustStore,
|
||||
timers: host.timers,
|
||||
trust: project.Trust,
|
||||
timers: project.Host.timers,
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
view: ClientView{
|
||||
ID: "loopback",
|
||||
ProjectKey: opts.ProjectKey,
|
||||
ProjectKey: project.Key,
|
||||
ProjectName: project.Name,
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
},
|
||||
@@ -198,7 +141,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
settingsPath: settingsPath,
|
||||
ctx: ctx,
|
||||
}
|
||||
st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings {
|
||||
st.summaries = newSummaryManager(project.Session, project.Dir, presets, func() autoSummarySettings {
|
||||
st.settingsMu.Lock()
|
||||
defer st.settingsMu.Unlock()
|
||||
return st.settings.AutoSummary.clone()
|
||||
@@ -210,13 +153,10 @@ func Run(ctx context.Context, opts Options) error {
|
||||
st.flashError(fmt.Sprintf("summary: %v", result.Error))
|
||||
}
|
||||
})
|
||||
sess.SetMetrics(metrics)
|
||||
host.attention = st
|
||||
host.focus = st
|
||||
host.prompter = st
|
||||
host.scratch = st
|
||||
project.Session.SetMetrics(metrics)
|
||||
st.attachProjectSinks(project)
|
||||
st.lastExit.Store(-1)
|
||||
sess.Subscribe(st)
|
||||
project.Session.Subscribe(st)
|
||||
go st.summaries.run(ctx)
|
||||
|
||||
st.enterScreen()
|
||||
@@ -227,15 +167,13 @@ func Run(ctx context.Context, opts Options) error {
|
||||
|
||||
// Set initial PTY grid for any future child. The child gets the
|
||||
// computed main viewport, excluding tab bar, sidebar, and status.
|
||||
sess.ResizeAll(layout.childCols(), layout.childRows())
|
||||
launcher.SetSize(layout.childCols(), layout.childRows())
|
||||
host.SetSize(layout.childCols(), layout.childRows())
|
||||
registry.ResizeAll(layout.childCols(), layout.childRows())
|
||||
|
||||
// Replay persisted top-level command processes. Failures are
|
||||
// logged and skipped so a stale entry (preset deleted, binary
|
||||
// missing) doesn't block startup.
|
||||
for _, e := range savedProcesses {
|
||||
c, err := launcher.RestoreCommand(e, presets)
|
||||
for _, e := range project.savedProcess {
|
||||
c, err := project.Launcher.RestoreCommand(e, presets)
|
||||
if err != nil {
|
||||
st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err)
|
||||
continue
|
||||
@@ -281,9 +219,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
st.renderer.SetLayout(l)
|
||||
}
|
||||
st.mu.Unlock()
|
||||
sess.ResizeAll(l.childCols(), l.childRows())
|
||||
launcher.SetSize(l.childCols(), l.childRows())
|
||||
host.SetSize(l.childCols(), l.childRows())
|
||||
registry.ResizeAll(l.childCols(), l.childRows())
|
||||
st.clearScreen()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
@@ -420,6 +356,8 @@ func Run(ctx context.Context, opts Options) error {
|
||||
// uiState is the shared state between the SIGWINCH loop, the stdin
|
||||
// loop, and the session listener callbacks.
|
||||
type uiState struct {
|
||||
registry *ProjectRegistry
|
||||
project *Project
|
||||
sess *Session
|
||||
presets preset.Set
|
||||
launcher *Launcher
|
||||
@@ -532,6 +470,97 @@ type uiState struct {
|
||||
lastExit atomic.Int32
|
||||
}
|
||||
|
||||
func (st *uiState) attachProjectSinks(p *Project) {
|
||||
p.Host.attention = st
|
||||
p.Host.focus = st
|
||||
p.Host.prompter = st
|
||||
p.Host.scratch = st
|
||||
}
|
||||
|
||||
func (st *uiState) detachProjectSinks(p *Project) {
|
||||
if p == nil || p.Host == nil {
|
||||
return
|
||||
}
|
||||
if p.Host.attention == st {
|
||||
p.Host.attention = nil
|
||||
}
|
||||
if p.Host.focus == st {
|
||||
p.Host.focus = nil
|
||||
}
|
||||
if p.Host.prompter == st {
|
||||
p.Host.prompter = nil
|
||||
}
|
||||
if p.Host.scratch == st {
|
||||
p.Host.scratch = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (st *uiState) switchProject(p *Project) {
|
||||
if p == nil || p.Session == nil {
|
||||
return
|
||||
}
|
||||
oldProject := st.project
|
||||
old := st.sess
|
||||
if old != nil && old != p.Session {
|
||||
old.Unsubscribe(st)
|
||||
st.detachProjectSinks(oldProject)
|
||||
}
|
||||
st.attachProjectSinks(p)
|
||||
p.Session.SetMetrics(st.metrics)
|
||||
if old != p.Session {
|
||||
p.Session.Subscribe(st)
|
||||
}
|
||||
layout := st.layoutSnapshot()
|
||||
p.Session.ResizeAll(layout.childCols(), layout.childRows())
|
||||
p.Launcher.SetSize(layout.childCols(), layout.childRows())
|
||||
p.Host.SetSize(layout.childCols(), layout.childRows())
|
||||
|
||||
children := p.Session.Children()
|
||||
next := firstRunningTopLevel(children)
|
||||
active := firstRunningAgentID(children)
|
||||
|
||||
st.mu.Lock()
|
||||
st.project = p
|
||||
st.sess = p.Session
|
||||
st.launcher = p.Launcher
|
||||
st.pads = p.Pads
|
||||
st.trust = p.Trust
|
||||
st.timers = p.Host.timers
|
||||
st.view.ProjectKey = p.Key
|
||||
st.view.ProjectName = p.Name
|
||||
st.view.FocusedID = ""
|
||||
st.view.FocusedPad = ""
|
||||
st.view.ActiveAgentID = active
|
||||
st.focusedID = ""
|
||||
st.focusedPad = ""
|
||||
st.focusedName = ""
|
||||
st.activeAgentID = active
|
||||
st.padOffset = 0
|
||||
st.padOffsetName = ""
|
||||
st.view.PadOffset = 0
|
||||
st.view.PadOffsetName = ""
|
||||
st.renderer = nil
|
||||
if next != nil {
|
||||
st.focusChildLocked(next)
|
||||
st.updateActiveAgentLocked(next)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
}
|
||||
st.palette = nil
|
||||
st.mu.Unlock()
|
||||
|
||||
st.invalidateScratchpadsCache()
|
||||
st.invalidateChromeCache()
|
||||
st.clearScreen()
|
||||
if next != nil {
|
||||
st.repaintFocused()
|
||||
} else {
|
||||
st.renderEmptyState()
|
||||
}
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
func (st *uiState) dbgf(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
}
|
||||
@@ -1300,6 +1329,10 @@ func (st *uiState) drawStatusLine() {
|
||||
palOpen := st.palette != nil
|
||||
focusID := st.focusedID
|
||||
focusName := st.focusedName
|
||||
projectName := ""
|
||||
if st.project != nil && st.registry != nil && st.registry.Count() > 1 {
|
||||
projectName = st.project.Name
|
||||
}
|
||||
var trustMsg string
|
||||
if st.pendingTrust != nil {
|
||||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||||
@@ -1328,10 +1361,14 @@ func (st *uiState) drawStatusLine() {
|
||||
owner = "you have control"
|
||||
}
|
||||
}
|
||||
left := ""
|
||||
left := projectName
|
||||
if focusName != "" {
|
||||
if left != "" {
|
||||
left = left + " · " + focusName
|
||||
} else {
|
||||
left = focusName
|
||||
}
|
||||
}
|
||||
if owner != "" {
|
||||
if left != "" {
|
||||
left = left + " · " + owner
|
||||
@@ -2003,6 +2040,20 @@ func (st *uiState) openPaletteLocked() {
|
||||
appSettings := st.settings.clone()
|
||||
st.settingsMu.Unlock()
|
||||
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
|
||||
if st.registry != nil {
|
||||
projects := st.registry.Summaries(st.view.ProjectKey)
|
||||
palProjects := make([]paletteProject, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
palProjects = append(palProjects, paletteProject{
|
||||
Key: p.Key,
|
||||
Dir: p.Dir,
|
||||
Name: p.Name,
|
||||
TabCount: p.TabCount,
|
||||
IsCurrent: p.IsCurrent,
|
||||
})
|
||||
}
|
||||
st.palette.setProjects(st.view.ProjectKey, palProjects)
|
||||
}
|
||||
// Push a "no kitty flags" entry onto the host terminal's keyboard
|
||||
// stack so palette input arrives in plain legacy form regardless of
|
||||
// what the focused child pushed. Codex/ratatui enables kitty mode
|
||||
@@ -2138,6 +2189,42 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
|
||||
case "project-switch":
|
||||
if st.registry == nil || action.projectKey == "" {
|
||||
restoreView()
|
||||
return
|
||||
}
|
||||
if p := st.registry.Project(action.projectKey); p != nil {
|
||||
st.switchProject(p)
|
||||
return
|
||||
}
|
||||
restoreView()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
|
||||
case "project-open-submit":
|
||||
if st.registry == nil || strings.TrimSpace(action.projectPath) == "" {
|
||||
restoreView()
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(action.projectPath)
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
}
|
||||
p, err := st.registry.Open(st.ctx, path)
|
||||
if err != nil {
|
||||
st.flashError(fmt.Sprintf("open project: %v", err))
|
||||
restoreView()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
return
|
||||
}
|
||||
st.switchProject(p)
|
||||
|
||||
case "kill":
|
||||
// User-initiated kill cancels any pending auto-restart so the
|
||||
// process doesn't immediately come back.
|
||||
|
||||
@@ -6,6 +6,7 @@ import "github.com/hjbdev/patterm/internal/scratchpad"
|
||||
// ANSI output; this model is the serializable shape a client can draw locally.
|
||||
type chromeModel struct {
|
||||
ProjectKey string `json:"project_key"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
FocusedID string `json:"focused_id,omitempty"`
|
||||
FocusedPad string `json:"focused_pad,omitempty"`
|
||||
ActiveAgentID string `json:"active_agent_id,omitempty"`
|
||||
@@ -41,6 +42,7 @@ func buildChromeModel(projectKey string, view ClientView, children []*Child, pad
|
||||
}
|
||||
model := chromeModel{
|
||||
ProjectKey: projectKey,
|
||||
ProjectName: view.ProjectName,
|
||||
FocusedID: view.FocusedID,
|
||||
FocusedPad: view.FocusedPad,
|
||||
ActiveAgentID: active,
|
||||
|
||||
@@ -6,6 +6,7 @@ package app
|
||||
type ClientView struct {
|
||||
ID string
|
||||
ProjectKey string
|
||||
ProjectName string
|
||||
FocusedID string
|
||||
FocusedPad string
|
||||
ActiveAgentID string
|
||||
|
||||
@@ -1,29 +1,447 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/persist"
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
"github.com/hjbdev/patterm/internal/projectkey"
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
"github.com/hjbdev/patterm/internal/trust"
|
||||
)
|
||||
|
||||
// headlessCore is the daemon-owned half of today's single-process app. It is
|
||||
// intentionally small for the foundation phase: it groups process/project
|
||||
// state while the existing loopback client still renders in-process.
|
||||
type headlessCore struct {
|
||||
projectDir string
|
||||
projectKey string
|
||||
type Project struct {
|
||||
Key string
|
||||
Dir string
|
||||
Name string
|
||||
|
||||
Session *Session
|
||||
Pads *scratchpad.Store
|
||||
Trust *trust.Store
|
||||
Persist *persist.Store
|
||||
Launcher *Launcher
|
||||
Host *toolHost
|
||||
savedProcess []persist.Entry
|
||||
|
||||
lastActive time.Time
|
||||
}
|
||||
|
||||
type projectSummary struct {
|
||||
Key string
|
||||
Dir string
|
||||
Name string
|
||||
TabCount int
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
// ProjectRegistry is the daemon-owned project map. Phase 1 still runs in one
|
||||
// local process, but every project already has isolated stores, session,
|
||||
// launcher, and tool host so future clients can attach to different projects.
|
||||
type ProjectRegistry struct {
|
||||
mu sync.Mutex
|
||||
projects map[string]*Project
|
||||
|
||||
defaultProjectKey string
|
||||
presets preset.Set
|
||||
settings settings
|
||||
|
||||
pads *scratchpad.Store
|
||||
trustStore *trust.Store
|
||||
persistStore *persist.Store
|
||||
|
||||
mcpSrv *mcp.Server
|
||||
sess *Session
|
||||
launcher *Launcher
|
||||
host *toolHost
|
||||
cols, rows uint16
|
||||
}
|
||||
|
||||
func newProjectRegistry(presets preset.Set, settings settings, mcpSrv *mcp.Server, cols, rows uint16) *ProjectRegistry {
|
||||
return &ProjectRegistry{
|
||||
projects: make(map[string]*Project),
|
||||
presets: presets,
|
||||
settings: settings,
|
||||
mcpSrv: mcpSrv,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error) {
|
||||
key, err := projectkey.Key(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if p := r.projects[key]; p != nil {
|
||||
p.lastActive = time.Now()
|
||||
r.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
pads, err := scratchpad.Open(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app: scratchpad init: %w", err)
|
||||
}
|
||||
trustStore, err := trust.Open(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app: trust init: %w", err)
|
||||
}
|
||||
persistStore, err := persist.Open(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app: persist init: %w", err)
|
||||
}
|
||||
sess := NewSession(abs, key)
|
||||
savedProcesses := persistStore.List()
|
||||
for _, e := range savedProcesses {
|
||||
_ = persistStore.Remove(e.ID)
|
||||
}
|
||||
sess.SetPersistStore(persistStore)
|
||||
socket := ""
|
||||
if r.mcpSrv != nil {
|
||||
socket = r.mcpSrv.Socket()
|
||||
}
|
||||
launcher := NewLauncher(sess, socket, r.cols, r.rows)
|
||||
host := newToolHost(sess, pads, launcher, r.presets, trustStore, r.cols, r.rows)
|
||||
go sess.runClassifier(ctx)
|
||||
|
||||
p := &Project{
|
||||
Key: key,
|
||||
Dir: abs,
|
||||
Name: filepath.Base(abs),
|
||||
Session: sess,
|
||||
Pads: pads,
|
||||
Trust: trustStore,
|
||||
Persist: persistStore,
|
||||
Launcher: launcher,
|
||||
Host: host,
|
||||
savedProcess: savedProcesses,
|
||||
lastActive: time.Now(),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if existing := r.projects[key]; existing != nil {
|
||||
r.mu.Unlock()
|
||||
sess.Shutdown()
|
||||
return existing, nil
|
||||
}
|
||||
r.projects[key] = p
|
||||
if r.defaultProjectKey == "" {
|
||||
r.defaultProjectKey = key
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Project(key string) *Project {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.projects[key]
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.projects)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Shutdown() {
|
||||
r.mu.Lock()
|
||||
projects := make([]*Project, 0, len(r.projects))
|
||||
for _, p := range r.projects {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, p := range projects {
|
||||
p.Session.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ResizeAll(cols, rows uint16) {
|
||||
r.mu.Lock()
|
||||
r.cols, r.rows = cols, rows
|
||||
projects := make([]*Project, 0, len(r.projects))
|
||||
for _, p := range r.projects {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, p := range projects {
|
||||
p.Session.ResizeAll(cols, rows)
|
||||
p.Launcher.SetSize(cols, rows)
|
||||
p.Host.SetSize(cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Summaries(currentKey string) []projectSummary {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]projectSummary, 0, len(r.projects))
|
||||
for _, p := range r.projects {
|
||||
out = append(out, projectSummary{
|
||||
Key: p.Key,
|
||||
Dir: p.Dir,
|
||||
Name: p.Name,
|
||||
TabCount: len(runningTopLevels(p.Session.Children())),
|
||||
IsCurrent: p.Key == currentKey,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IsCurrent != out[j].IsCurrent {
|
||||
return out[i].IsCurrent
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) findProjectByChild(id string) (*Project, *Child) {
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
projects := make([]*Project, 0, len(r.projects))
|
||||
for _, p := range r.projects {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, p := range projects {
|
||||
if c := p.Session.FindChild(id); c != nil {
|
||||
return p, c
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) projectForCaller(callerID string) *Project {
|
||||
if p, _ := r.findProjectByChild(callerID); p != nil {
|
||||
return p
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.projects[r.defaultProjectKey]
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) hostForCaller(callerID string) *toolHost {
|
||||
if p := r.projectForCaller(callerID); p != nil {
|
||||
return p.Host
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) hostForProcess(processID string) *toolHost {
|
||||
if p, _ := r.findProjectByChild(processID); p != nil {
|
||||
return p.Host
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ResolveCallerIdentity(identity string) string {
|
||||
r.mu.Lock()
|
||||
projects := make([]*Project, 0, len(r.projects))
|
||||
for _, p := range r.projects {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, p := range projects {
|
||||
if c := p.Session.FindChildByIdentity(identity); c != nil {
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) CallerRole(processID string) mcp.CallerRole {
|
||||
if h := r.hostForCaller(processID); h != nil {
|
||||
return h.CallerRole(processID)
|
||||
}
|
||||
return mcp.RoleOrchestrator
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) {
|
||||
return r.hostForCaller(callerID).SpawnAgent(callerID, args)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) {
|
||||
return r.hostForCaller(callerID).SpawnProcess(callerID, args)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.StartProcess(callerID, processID)
|
||||
}
|
||||
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.RestartProcess(callerID, processID, sig)
|
||||
}
|
||||
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.StopProcess(callerID, processID, sig)
|
||||
}
|
||||
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) CloseProcess(callerID, processID string) error {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.CloseProcess(callerID, processID)
|
||||
}
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) RenameProcess(callerID, processID, name string) error {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.RenameProcess(callerID, processID, name)
|
||||
}
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SelectProcess(callerID, processID string) error {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.SelectProcess(callerID, processID)
|
||||
}
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo {
|
||||
if h := r.hostForCaller(callerID); h != nil {
|
||||
return h.ListProcesses(callerID, kindFilter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.GetProcessStatus(callerID, processID)
|
||||
}
|
||||
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
|
||||
return r.hostForCaller(callerID).GetProjectStatus(callerID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.GetProcessOutput(callerID, processID, mode, sinceOffset)
|
||||
}
|
||||
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.GetProcessRawOutput(callerID, processID, sinceOffset)
|
||||
}
|
||||
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.SearchOutput(callerID, processID, pattern, kind, limit)
|
||||
}
|
||||
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.WaitForPattern(callerID, processID, pattern, timeoutSeconds, scope)
|
||||
}
|
||||
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.GetProcessPorts(callerID, processID)
|
||||
}
|
||||
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
|
||||
if h := r.hostForProcess(args.ProcessID); h != nil {
|
||||
return h.SendInput(callerID, args)
|
||||
}
|
||||
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) SendMessage(callerID, targetID, message string) error {
|
||||
if h := r.hostForProcess(targetID); h != nil {
|
||||
return h.SendMessage(callerID, targetID, message)
|
||||
}
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) RequestHumanAttention(callerID, processID, reason string) error {
|
||||
if h := r.hostForProcess(processID); h != nil {
|
||||
return h.RequestHumanAttention(callerID, processID, reason)
|
||||
}
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
||||
return r.hostForCaller(callerID).TimerWait(callerID, seconds, label)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
|
||||
return r.hostForCaller(callerID).TimerSet(callerID, args)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
return r.hostForCaller(callerID).TimerFireWhenIdleAny(callerID, args)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
return r.hostForCaller(callerID).TimerFireWhenIdleAll(callerID, args)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerCancel(callerID, id string) error {
|
||||
return r.hostForCaller(callerID).TimerCancel(callerID, id)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerPause(callerID, id string) error {
|
||||
return r.hostForCaller(callerID).TimerPause(callerID, id)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerResume(callerID, id string) error {
|
||||
return r.hostForCaller(callerID).TimerResume(callerID, id)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) {
|
||||
return r.hostForCaller(callerID).TimerList(callerID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ScratchpadList() ([]scratchpad.Entry, error) {
|
||||
return r.hostForCaller("").ScratchpadList()
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) {
|
||||
return r.hostForCaller("").ScratchpadRead(name)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
|
||||
return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ScratchpadAppend(name, content string) error {
|
||||
return r.hostForCaller("").ScratchpadAppend(name, content)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) ScratchpadDelete(name string) error {
|
||||
return r.hostForCaller("").ScratchpadDelete(name)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI {
|
||||
return r.hostForCaller(callerID).WhoAmI(callerID)
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Help(callerID, topic string) mcp.HelpResponse {
|
||||
return r.hostForCaller(callerID).Help(callerID, topic)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ type paletteAction struct {
|
||||
|
||||
// For settings actions, the updated settings snapshot to persist.
|
||||
settings *settings
|
||||
|
||||
projectKey string
|
||||
projectPath string
|
||||
}
|
||||
|
||||
// Group ids order the section bands the palette renders when no query
|
||||
@@ -48,6 +51,7 @@ type paletteAction struct {
|
||||
// an equally tight Spawn-section hit.
|
||||
const (
|
||||
groupFocused = iota
|
||||
groupProject
|
||||
groupOpen
|
||||
groupSpawn
|
||||
groupSettings
|
||||
@@ -64,6 +68,14 @@ type paletteItem struct {
|
||||
matches []int
|
||||
}
|
||||
|
||||
type paletteProject struct {
|
||||
Key string
|
||||
Dir string
|
||||
Name string
|
||||
TabCount int
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
// paletteMode toggles the palette between its fuzzy-picker UI and the
|
||||
// freeform "spawn process" form. The form lives inside the palette so
|
||||
// it shares the same modal-input contract (every byte intercepted; no
|
||||
@@ -124,6 +136,8 @@ type paletteState struct {
|
||||
form *spawnProcessForm
|
||||
renameForm *renameForm
|
||||
settingsInput *settingsInputForm
|
||||
projects []paletteProject
|
||||
currentProject string
|
||||
|
||||
// showHelp swaps the item list for a static keybinding cheat-sheet
|
||||
// until the next keystroke. Toggled by `?` in picker mode.
|
||||
@@ -189,6 +203,12 @@ func newPalette(children []*Child, focused, focusedPad string, presets preset.Se
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *paletteState) setProjects(current string, projects []paletteProject) {
|
||||
p.currentProject = current
|
||||
p.projects = append(p.projects[:0], projects...)
|
||||
p.rebuild()
|
||||
}
|
||||
|
||||
func (p *paletteState) rebuild() {
|
||||
// Macro is resolved on the *original-case* query; the returned rest
|
||||
// keeps the user's casing intact (useful when Tab cycles chips).
|
||||
@@ -294,7 +314,33 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
}
|
||||
}
|
||||
|
||||
// Group 1: Open — switch entries for every running child *other than*
|
||||
if p.projects != nil {
|
||||
// Group 1: Project — move the current client view without tearing
|
||||
// down processes owned by the previous project.
|
||||
for _, pr := range p.projects {
|
||||
if pr.IsCurrent || pr.Key == p.currentProject {
|
||||
continue
|
||||
}
|
||||
hint := pr.Dir
|
||||
if pr.TabCount > 0 {
|
||||
hint = fmt.Sprintf("%s · %d tabs", hint, pr.TabCount)
|
||||
}
|
||||
out = append(out, paletteItem{
|
||||
label: "Switch project: " + pr.Name,
|
||||
hint: hint,
|
||||
action: paletteAction{kind: "project-switch", projectKey: pr.Key},
|
||||
group: groupProject,
|
||||
})
|
||||
}
|
||||
out = append(out, paletteItem{
|
||||
label: "Open project…",
|
||||
hint: "attach this client view to another local directory",
|
||||
action: paletteAction{kind: "project-open-form"},
|
||||
group: groupProject,
|
||||
})
|
||||
}
|
||||
|
||||
// Group 2: Open — switch entries for every running child *other than*
|
||||
// the one already focused (no point offering a no-op switch). Dead
|
||||
// agents are filtered out (no restart path); dead command processes
|
||||
// remain so they can be restarted.
|
||||
@@ -655,6 +701,9 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
|
||||
p.cursor = 0
|
||||
p.rebuildSettings()
|
||||
return paletteAction{}, false, adv
|
||||
case "project-open-form":
|
||||
p.enterRenameForm("project", "", "", "project path")
|
||||
return paletteAction{}, false, adv
|
||||
case "pad-rename-form":
|
||||
p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName)
|
||||
return paletteAction{}, false, adv
|
||||
@@ -913,6 +962,9 @@ func (p *paletteState) submitRename() paletteAction {
|
||||
return paletteAction{kind: "cancel"}
|
||||
}
|
||||
newName := strings.TrimSpace(string(p.renameForm.name))
|
||||
if p.renameForm.subject == "project" {
|
||||
return paletteAction{kind: "project-open-submit", projectPath: newName}
|
||||
}
|
||||
if newName == "" {
|
||||
return paletteAction{kind: "cancel"}
|
||||
}
|
||||
|
||||
100
internal/app/project_registry_test.go
Normal file
100
internal/app/project_registry_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
)
|
||||
|
||||
func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
|
||||
defer reg.Shutdown()
|
||||
|
||||
projectA, err := reg.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open project A: %v", err)
|
||||
}
|
||||
projectB, err := reg.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open project B: %v", err)
|
||||
}
|
||||
|
||||
a, err := projectA.Session.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
|
||||
Name: "a-loop",
|
||||
}, 80, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("spawn project A command: %v", err)
|
||||
}
|
||||
b, err := projectB.Session.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
|
||||
Name: "b-loop",
|
||||
}, 80, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("spawn project B command: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
|
||||
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
|
||||
})
|
||||
waitUntilLive(t, a)
|
||||
waitUntilLive(t, b)
|
||||
|
||||
st := &uiState{
|
||||
registry: reg,
|
||||
project: projectA,
|
||||
sess: projectA.Session,
|
||||
launcher: projectA.Launcher,
|
||||
pads: projectA.Pads,
|
||||
trust: projectA.Trust,
|
||||
timers: projectA.Host.timers,
|
||||
chromeWake: make(chan struct{}, 1),
|
||||
view: ClientView{
|
||||
ID: "test",
|
||||
ProjectKey: projectA.Key,
|
||||
ProjectName: projectA.Name,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
},
|
||||
}
|
||||
st.focusChildLocked(a)
|
||||
projectA.Session.Subscribe(st)
|
||||
|
||||
st.switchProject(projectB)
|
||||
if st.view.ProjectKey != projectB.Key {
|
||||
t.Fatalf("view project key = %q, want %q", st.view.ProjectKey, projectB.Key)
|
||||
}
|
||||
if st.sess != projectB.Session {
|
||||
t.Fatalf("ui session did not move to project B")
|
||||
}
|
||||
if projectA.Session.FindChild(a.ID) == nil {
|
||||
t.Fatalf("project A child disappeared after switch")
|
||||
}
|
||||
if projectB.Session.FindChild(b.ID) == nil {
|
||||
t.Fatalf("project B child disappeared after switch")
|
||||
}
|
||||
if !a.IsLive() {
|
||||
t.Fatalf("project A child stopped after switch")
|
||||
}
|
||||
if !b.IsLive() {
|
||||
t.Fatalf("project B child stopped after switch")
|
||||
}
|
||||
|
||||
st.switchProject(projectA)
|
||||
if st.view.ProjectKey != projectA.Key {
|
||||
t.Fatalf("view project key after switching back = %q, want %q", st.view.ProjectKey, projectA.Key)
|
||||
}
|
||||
if projectA.Session.FindChild(a.ID) == nil || projectB.Session.FindChild(b.ID) == nil {
|
||||
t.Fatalf("switching back should preserve both project process trees")
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,9 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
if spec.WorkDir == "" {
|
||||
spec.WorkDir = s.projectDir
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
id := s.mintUniqueIDLocked()
|
||||
|
||||
@@ -188,6 +188,9 @@ func RunStdioProxy(socket, identity string) error {
|
||||
// "<token>"} + newline. Real protocol handshake is a later
|
||||
// milestone.
|
||||
greeting := map[string]string{"patterm_identity": identity}
|
||||
if key := os.Getenv("PATTERM_PROJECT_KEY"); key != "" {
|
||||
greeting["project_key"] = key
|
||||
}
|
||||
gb, _ := json.Marshal(greeting)
|
||||
gb = append(gb, '\n')
|
||||
if _, err := conn.Write(gb); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user