Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/contr/e2e_see_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ var seeFixtures = map[string]seeFixture{
},
ready: "Sidebar",
},
"empty-project": {
about: "the workers fixture and a stored project, site, with nothing open (D291); press its row, or right-click it for its menu",
setup: func(t *testing.T, h *harness) {
seeWorkers(t, h)
h.storedProject(t, "site")
},
ready: "site, ",
},
"workers": {
about: "project demo: an orchestrator and two builders, one working",
setup: seeWorkers,
Expand Down Expand Up @@ -668,6 +676,28 @@ func (h *harness) recentProject(t *testing.T, name string) {
}
}

// storedProject makes the git repo name in the scratch folder and stores it as an
// added folder with no terminal: an empty project (D116).
func (h *harness) storedProject(t *testing.T, name string) {
t.Helper()
dir := filepath.Join(h.home, "Library", "Application Support", "contr")
path := h.repo(t, name, "main")
if real, err := filepath.EvalSymlinks(path); err == nil {
path = real
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
doc, err := json.Marshal(map[string]any{"version": 1, "order": []string{filepath.Join(path, ".git")},
"folders": map[string]string{filepath.Join(path, ".git"): path}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "projects.json"), doc, 0o600); err != nil {
t.Fatal(err)
}
}

func seeWorkers(t *testing.T, h *harness) { seeWorkersRepo(t, h) }

// seeNonGit is the workers fixture with `+ new` set to pick a folder outside
Expand Down
9 changes: 5 additions & 4 deletions docs/DECISIONS.md

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions docs/spec/ProjectRow.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,22 @@ Design system page: component ProjectRow. Shared rules: [README.md](README.md).
icon set has no chevron yet, so the row shows no fold mark; the state is
in the screen-reader label (expanded). A folded project hides its agent
rows, which sit under it in the same list (#344).
- An empty project has no `+` on its row (D171). Its context menu has "New
orchestrator in <name>": it starts an orchestrator in the project folder
(D116). With the list focused and the key cursor on an empty project, the
menu key opens that menu, and Enter starts the orchestrator (D174).
- Each project row has a context menu (D219). A project with agents has
"Close all agents": it stops each agent of the project and removes its
terminal, also an ended one, with no second click. Each project has "Hide project": it hides the project and shows a
toast with Undo (D207); it stops nothing. The menu key opens the menu of
the project under the key cursor.
- An empty project has no `+` on its row (D171). With the list focused and
the key cursor on an empty project, the menu key opens its menu; Enter
starts nothing (D174).
- Each project row has a context menu (D219, D291): "New orchestrator" while
the project has no orchestrator, "New terminal", a separator, "Close all
agents" while it has agents, and "Close project". The two starts run in the
project's folder (main checkout, else its first worktree, else the stored
folder), as the `+` menu and New tab do. "Close all agents" stops each
agent of the project and removes its terminal, also an ended one, with no
second click. "Close project" takes the project out of the sidebar and
shows the toast "<name> closed" with Undo (D207); it stops nothing, and
Open projects brings it back. The menu key opens the menu of the project
under the key cursor.
- A click on an empty project selects it. The empty terminal then shows
"New orchestrator" and "New terminal ⌘T" for that project, and ⌘T opens
the terminal there (D291).
- Empty projects sit after the projects with agents; the others keep the
dragged order (D174).
- The Projects head has one `+` button. Its menu has "New orchestrator" and
Expand Down Expand Up @@ -86,7 +93,7 @@ Design system page: component ProjectRow. Shared rules: [README.md](README.md).
- One `ui.List` holds the projects and their agents; each row is a `kit.Row`. Do not use `ui.Sidebar`: it shows the selection in the accent color.
- Open and close a project group with `CollapsibleBase`, or keep a collapsed set in the list state.
- Reorder projects by drag with `ListState.Reorder`; store the order with `internal/projects`.
- `.ContextMenu(...)`: Close all agents, Hide project (D219). Open in Finder and Remove wait.
- `.ContextMenu(...)`: New orchestrator, New terminal, Close all agents, Close project (D219, D291). Open in Finder and Remove wait.

## Do and do not

Expand Down
18 changes: 9 additions & 9 deletions docs/spec/ProjectsModal.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ Opens from the "Open projects" item of the Projects head `+` menu (D171). The ow
│ ⌕ Search projects, or paste a GitHub repo or a path esc │ search
├─────────────────────────────────────────────────────────────────────┤
│ Open · 2 drag to reorder the sidebar │ section + hint
│ ⋮⋮ [C ] contr ~/code/contr · main · orchestrator idle Hide ⏎ switch │
│ ⋮⋮ [C ] contr ~/code/contr · main · orchestrator idle Close ⏎ switch │
│ ⋮⋮ [CR] contr-rune ~/code/contr-rune · main · 1 needs you │
│ Recent │
│ [S ] site ~/code/site · main 10m ago │
├─────────────────────────────────────────────────────────────────────┤
│ ↑↓ move ⏎ switch ⌘⌫ hide [Open folder… ⌘O] │ footer
│ ↑↓ move ⏎ switch ⌘⌫ close project [Open folder… ⌘O] │ footer
└─────────────────────────────────────────────────────────────────────┘
toast: "<name> hidden from the sidebar" Undo
toast: "<name> closed" Undo
```

| Part | Value |
Expand All @@ -33,13 +33,13 @@ Opens from the "Open projects" item of the Projects head `+` menu (D171). The ow
| Name | Weight 600 `ink-100`; a recent name is weight 500 `ink-88`. One line, ellipsis. The part that matches the field is underlined in `ink-100`. |
| Meta | Mono 11.5 px `ink-48`, one line, ellipsis. |
| Need you | The `N need you` part of the meta is `blocked` text. This is the one exception to the `blocked` rule (Q1). |
| Row actions | `Hide`: 12 px `ink-48`, radius 6, hover fill `hover`; shows on hover or selection. The hint is mono 11 px `ink-48`, only on the selected row. A recent row shows its age (mono 11 px `ink-48`) instead; the age hides on hover or selection. |
| Row actions | `Close` (D291): 12 px `ink-48`, radius 6, hover fill `hover`; shows on hover or selection. The hint is mono 11 px `ink-48`, only on the selected row. A recent row shows its age (mono 11 px `ink-48`) instead; the age hides on hover or selection. |
| Progress | 2 px line, radius 2, track `hairline`, bar `ink-64`, indeterminate (Q3). |
| Error line | Mono 11.5 px `blocked`, wraps, under the meta. |
| Empty | Text `ink-48`, centred, padding 22×12. |
| Footer | Padding 10×16, `hairline` above, text 11.5 px `ink-48`, gap 14. `kbd`: mono 10.5 px weight 500 `ink-48`, border `input-line`, radius 5, padding 3×5. |
| Ghost button | `Open folder… ⌘O`: border `input-line`, radius 7, padding 5×10, 12 px `ink-64`; hover fill `hover` and `ink-100`. Hidden when the app has no folder picker. |
| Toast | Shown in the frame's toast viewport, so it outlives the dialog. Text `<name> hidden from the sidebar`; action `Undo`: weight 600 `ink-100`, no fill. |
| Toast | Shown in the frame's toast viewport, so it outlives the dialog. Text `<name> closed`; action `Undo`: weight 600 `ink-100`, no fill. |

## States

Expand All @@ -65,9 +65,9 @@ Exact strings:
- Sections: `Add`, `Open · N`, `Recent`.
- Heading hint (not while filtering): `drag to reorder the sidebar`.
- Add rows: `Clone owner/name`, meta `into <parent>/<name>`, `Change folder`, `Cancel`, `Try again`, `Open <folder>` (the path is the meta).
- Hide: `Hide`; toast `<name> hidden from the sidebar` with `Undo`.
- Close project: `Close`; toast `<name> closed` with `Undo`.
- Empty: `No project matches "<text>". Paste a GitHub repo or a path to add one.`
- Footer: `↑↓ move`, `⏎ <action>`, `⌘⌫ hide`, ghost `Open folder… ⌘O`.
- Footer: `↑↓ move`, `⏎ <action>`, `⌘⌫ close project`, ghost `Open folder… ⌘O`.
- A zero count never shows: no `0 worktrees`, no `0 need you`. The count reads `1 needs you` or `N need you` (N > 1).

## Behavior
Expand All @@ -92,7 +92,7 @@ Exact strings:
|---|---|
| `↑` `↓` | Move the selection; stop at the ends. |
| `⏎` | Run the selected row (see the Enter label). |
| `⌘⌫` | Hide the selected open row, only while the field is empty. With text in the field it deletes text, as in any field. The footer shows `⌘⌫ hide` only then. |
| `⌘⌫` | Hide the selected open row, only while the field is empty. With text in the field it deletes text, as in any field. The footer shows `⌘⌫ close project` only then. |
| `⌘O` | Open the folder dialog (same as the ghost button). |
| Esc | With text in the field: clear it. With an empty field: close the dialog (Q2). |

Expand All @@ -113,7 +113,7 @@ Exact strings:
## Accessibility

- The dialog label is `projects modal`; the field label is `Search projects, or paste a GitHub repo or a path`; the list label is `projects list`.
- A row label is `project <name>`; the `Hide` label is `Hide <name>`. The add rows are `clone row` and `path row`.
- A row label is `project <name>`; the `Close` label is `Close <name>`. The add rows are `clone row` and `path row`.
- The selected row reports selected. The progress line says `Clone progress`.

## Tokens
Expand Down
1 change: 1 addition & 0 deletions internal/app/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ func (f *Frame) attachSidebar(w Window, env view.Env, agentSnap func() agentstat
if f.deps.OpenTerminal != nil {
f.NewTerm = &NewTerminal{Open: f.deps.OpenTerminal, Env: env,
Select: func(addr string) { f.Selection.Select(state.Selected{Kind: state.KindTerminal, ID: addr}) }}
l.OnNewTerminalIn = f.NewTerm.Start
}
return func() {
f.SpacesList.Close()
Expand Down
7 changes: 7 additions & 0 deletions internal/app/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,13 @@ func (f *Frame) newTerminal() {
if f.NewTerm == nil {
return
}
if f.SpacesList != nil {
// A selected project row (an empty project, D291) is where the terminal opens.
if name, dir, ok := f.SpacesList.SelectedProject(); ok {
f.NewTerm.Start(name, dir)
return
}
}
project := f.openProject
f.NewTerm.Start(project, f.terminalDir(project))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/projectsview/projectsmodal.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const (
LabelProjectsFooter = "projects footer"
LabelProjectsDot = "orchestrator dot"
LabelProjectsSearch = "Search projects, or paste a GitHub repo or a path"
ProjectsHide = "Hide"
ProjectsHide = "Close"
ProjectsOpenFolder = "Open folder…"
LabelCloneRow = "clone row"
LabelPathRow = "path row"
Expand Down
4 changes: 2 additions & 2 deletions internal/app/projectsview/projectsmodal_hide.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
// ProjectsUndo is the label of the Undo button of the hide toast.
const ProjectsUndo = "Undo"

// hiddenToast is the text of the toast after Hide.
func hiddenToast(name string) string { return name + " hidden from the sidebar" }
// hiddenToast is the text of the toast after Close project (D291).
func hiddenToast(name string) string { return name + " closed" }

// Hide hides the project and offers Undo in a toast.
func (m *ProjectsModal) Hide(dir string) {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/projectsview/projectsmodal_hide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestHideShowsToastWithUndo(t *testing.T) {
if len(*calls) != 1 {
t.Fatalf("toasts: %d", len(*calls))
}
if c := (*calls)[0]; c.msg != "b hidden from the sidebar" || c.action != "Undo" || c.undo == nil {
if c := (*calls)[0]; c.msg != "b closed" || c.action != "Undo" || c.undo == nil {
t.Errorf("toast = %q %q", c.msg, c.action)
}
}
Expand Down
6 changes: 3 additions & 3 deletions internal/app/projectsview/projectsmodal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func TestProjectsModalCmdBackspaceWithTextDoesNotHide(t *testing.T) {
r.open()
r.tt.Type("a")
r.tt.Frame()
if slices.Contains(r.tt.Texts(), "hide") {
if slices.Contains(r.tt.Texts(), "close project") {
t.Error("the footer offers hide while the field has text")
}
r.tt.Key(ui.Cmd, ui.KeyBackspace) // deletes the text
Expand All @@ -273,14 +273,14 @@ func TestProjectsModalFooterOffersHideOnOpenRowOnly(t *testing.T) {
r := newModalRig(t)
r.store.Touch("/w/z/.git", "/w/z", time.Unix(1, 0))
r.open()
if !slices.Contains(r.tt.Texts(), "hide") {
if !slices.Contains(r.tt.Texts(), "close project") {
t.Error("no hide hint on an open row")
}
for range 3 {
r.tt.Key(0, ui.KeyDown)
}
r.tt.Frame()
if slices.Contains(r.tt.Texts(), "hide") {
if slices.Contains(r.tt.Texts(), "close project") {
t.Error("hide hint on a recent row")
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/projectsview/projectsmodal_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ func (m *ProjectsModal) footer(c *ui.Context, rows []paletteRow) {
hint("⏎", lb)
}
if m.sel < len(rows) && rows[m.sel].kind == rowOpen && m.text() == "" {
hint("⌘⌫", "hide")
hint("⌘⌫", "close project")
}
ui.Spacer(c)
if m.PickFolder == nil {
Expand Down
8 changes: 4 additions & 4 deletions internal/app/projectsview_frame_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import (
func TestHideToastOfTheFrameRunsUndoOnClick(t *testing.T) {
f, tt := toastFrame()
ran := false
f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() { ran = true })
f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() { ran = true })
tt.Frame()
tt.Frame()
if !slices.Contains(tt.Texts(), "b hidden from the sidebar") {
if !slices.Contains(tt.Texts(), "b closed") {
t.Fatalf("no toast in %q", tt.Texts())
}
if err := tt.Click(projectsview.ProjectsUndo); err != nil {
Expand All @@ -29,8 +29,8 @@ func TestHideToastOfTheFrameRunsUndoOnClick(t *testing.T) {

func TestHideToastsGetUniqueIDs(t *testing.T) {
f, tt := toastFrame()
f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() {})
f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() {})
f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() {})
f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() {})
a := f.Toasts.TakeActions()
if len(a) != 2 || a[0].ID == a[1].ID {
t.Errorf("ids = %v", a)
Expand Down
46 changes: 26 additions & 20 deletions internal/app/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package sidebar
// ui.List of kit.Row, then the Servers section with the This Mac row (D154). The model is in spaces.go.

import (
"slices"

"github.com/egoist/mygo/ui"

"github.com/contursofran/contr/internal/app/icons"
Expand All @@ -26,9 +28,12 @@ const (
LabelProjectsNew = "New orchestrator"
// LabelProjectsAdd names the `+` button of the Projects head; its menu has the two above.
LabelProjectsAdd = "Add"
// MenuCloseAll and MenuHideProject are the items of a project row's menu (D219).
MenuCloseAll = "Close all agents"
MenuHideProject = "Hide project"
// MenuNewOrchestrator, MenuNewTerminal, MenuCloseAll and MenuHideProject are the items
// of a project row's menu (D219, D291).
MenuNewOrchestrator = "New orchestrator"
MenuNewTerminal = "New terminal"
MenuCloseAll = "Close all agents"
MenuHideProject = "Close project"
// LabelNoProjects heads the empty project list (D255).
LabelNoProjects = "No projects yet"
// LabelOpenProject is the first-run button that opens the projects modal, as ⌘O does
Expand Down Expand Up @@ -83,9 +88,6 @@ func projectMeta(g spaces.RepoGroup) string {
return g.Branch
}

// LabelNewIn is the menu item of an empty project that starts an orchestrator there.
func LabelNewIn(g spaces.RepoGroup) string { return "New orchestrator in " + g.Name }

// View builds the sidebar head part.
func (l *SpacesList) View(c *ui.Context) {
ui.Column(c).FillWidth().FillHeight().Padding(theme.Space10, 0, 0, 0).Children(func() {
Expand Down Expand Up @@ -332,26 +334,30 @@ func (l *SpacesList) noteNewRows() {
}
}

// projectMenu is the menu of a project row (D219). An empty project (D116) has
// a new orchestrator in its folder; a project with agents has Close all agents:
// it stops each agent and removes its terminal, also one that already ended.
// Both have Hide project. Close all agents asks for no second click, as Stop
// of an agent row.
// projectMenu is the menu of a project row (D219, D291): New orchestrator while the
// project has none, New terminal, then Close all agents while it has agents, and Close
// project. The two starts run in the project's folder. Close all agents stops each agent
// and removes its terminal, also one that already ended, with no second click, as Stop of
// an agent row. Close project takes the project out of the sidebar and stops nothing.
func (l *SpacesList) projectMenu(m *ui.Menu, g spaces.RepoGroup) {
if g.Path != "" {
if m.Item(LabelNewIn(g)).Chosen() && l.OnNewIn != nil {
l.OnNewIn(g.Path)
lines := agentLines(g, l.Snapshot())
dir := projectRoot(g)
if !slices.ContainsFunc(lines, func(a agentLine) bool { return a.orchestrator }) {
if m.Item(MenuNewOrchestrator).Disabled(l.OnNewIn == nil).Chosen() && l.OnNewIn != nil {
l.OnNewIn(dir)
}
} else {
lines := agentLines(g, l.Snapshot())
off := l.Source == nil || len(lines) == 0
if m.Item(MenuCloseAll).Disabled(off).Chosen() && !off {
}
if m.Item(MenuNewTerminal).Disabled(l.OnNewTerminalIn == nil).Chosen() && l.OnNewTerminalIn != nil {
l.OnNewTerminalIn(g.Name, dir)
}
m.Separator()
if len(lines) > 0 && l.Source != nil {
if m.Item(MenuCloseAll).Chosen() {
for _, a := range lines {
l.act(a.w, "closed", l.Source.Close)
}
}
}
m.Separator()
if m.Item(MenuHideProject).Disabled(l.OnHide == nil).Chosen() && l.OnHide != nil {
l.OnHide(g.CommonDir)
}
Expand Down Expand Up @@ -381,7 +387,7 @@ func (l *SpacesList) projectRow(c *ui.Context, g spaces.RepoGroup, index int) {
}
if g.Path != "" {
// An empty project is dim: name ink-48 and icon ink-30, no `+` (#436). The
// start of an orchestrator in its folder (D116) is in the row's menu.
// starts in its folder (D116, D291) are in the row's menu.
o.NameInk, iconInk = p.Ink48, projectIconInk(p, false, true)
}
o.Menu = func(m *ui.Menu) { l.projectMenu(m, g) }
Expand Down
9 changes: 4 additions & 5 deletions internal/app/sidebar/sidebar_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/contursofran/contr/internal/app/apptest"
"github.com/contursofran/contr/internal/app/ghsource"
"github.com/contursofran/contr/internal/app/ghsource/ghsourcetest"
"github.com/contursofran/contr/internal/app/spaces"
"github.com/contursofran/contr/internal/projects"
)

Expand Down Expand Up @@ -162,7 +161,7 @@ func TestTheMenuKeyOnAnEmptyProjectOpensItsMenu(t *testing.T) {
h, started := emptyProjectRig(t)
moveToEmpty(t, h)
h.tt.Key(0, ui.KeyContextMenu)
if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err != nil {
if err := h.tt.ChooseMenuItem(MenuNewOrchestrator); err != nil {
t.Fatalf("no menu on the menu key: %v", err)
}
if !slices.Equal(*started, []string{"/proj/gamma"}) {
Expand All @@ -177,11 +176,11 @@ func TestTheMenuKeyOnAProjectWithAgentsOpensItsMenu(t *testing.T) {
}
h.tt.Key(0, ui.KeyDown) // the first row: a project with agents
h.tt.Key(0, ui.KeyContextMenu)
if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err == nil {
t.Error("the menu of a row with agents has a new orchestrator")
if slices.Contains(h.tt.Menu(), MenuNewOrchestrator) {
t.Error("the menu of a row with an orchestrator has a new orchestrator")
}
if !slices.Contains(h.tt.Menu(), MenuHideProject) {
t.Errorf("menu %q, want Hide project (D219)", h.tt.Menu())
t.Errorf("menu %q, want Close project (D219, D291)", h.tt.Menu())
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/app/sidebar/sidebar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,13 @@ func TestEmptyProjectMenuStartsAnOrchestratorThere(t *testing.T) {
var started []string
h.list.OnNewIn = func(dir string) { started = append(started, dir) }
h.tt.Frame()
if _, ok := h.tt.Find(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); ok {
if _, ok := h.tt.Find(MenuNewOrchestrator); ok {
t.Fatal("an empty project row shows a + button")
}
if err := h.tt.RightClick("gamma, nothing open"); err != nil {
t.Fatal(err)
}
if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err != nil {
if err := h.tt.ChooseMenuItem(MenuNewOrchestrator); err != nil {
t.Fatal(err)
}
if !slices.Equal(started, []string{"/proj/gamma"}) {
Expand Down
Loading
Loading