app: add loopback multi-project registry

This commit is contained in:
2026-05-27 13:40:59 +01:00
parent 08c7405c79
commit 80a14502c4
9 changed files with 798 additions and 125 deletions

View File

@@ -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
@@ -120,10 +132,12 @@ type paletteState struct {
items []paletteItem
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
settingsInput *settingsInputForm
mode paletteMode
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"}
}