Skip to content

Commit 171dbd6

Browse files
authored
feat(app): add worktree picker shortcuts and toggle shell mode (#60713)
1 parent 1a443ae commit 171dbd6

15 files changed

Lines changed: 425 additions & 72 deletions

File tree

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@
180180
"@corvu/[email protected]": "patches/@corvu%[email protected]",
181181
182182
183-
"@pierre/[email protected]": "patches/@pierre%[email protected]"
183+
"@pierre/[email protected]": "patches/@pierre%[email protected]",
184+
"@brendonovich/[email protected]": "patches/@brendonovich%[email protected]"
184185
}
185186
}

‎packages/app/e2e/regression/composer.spec.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,39 @@ test("/reload reloads the server's configuration once and reports it", async ({
11331133

11341134
const followUp = "Add follow-up, / for commands, @ for context…"
11351135

1136+
test("the shell shortcut toggles modes and keeps explicit bindings", async ({ page }) => {
1137+
const view = await openDraft(page, {
1138+
name: "ShellToggle",
1139+
seed: {
1140+
settings: {
1141+
keybinds: { "prompt.mode.shell": "mod+alt+s", "prompt.mode.normal": "mod+alt+e" },
1142+
},
1143+
},
1144+
})
1145+
1146+
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
1147+
1148+
const exit = page.getByRole("button", { name: /^Exit/ })
1149+
1150+
await view.editor.fill("echo hello")
1151+
await view.editor.press(`${modifier}+Shift+x`)
1152+
await expect(exit).toBeVisible()
1153+
await expect(view.editor).toHaveText("echo hello")
1154+
await view.editor.press(`${modifier}+Shift+x`)
1155+
await expect(exit).toHaveCount(0)
1156+
await expect(view.editor).toHaveText("echo hello")
1157+
await view.editor.press(`${modifier}+Shift+x`)
1158+
await expect(exit).toBeVisible()
1159+
await view.editor.press("Escape")
1160+
await expect(exit).toHaveCount(0)
1161+
await expect(view.editor).toBeFocused()
1162+
await view.editor.press(`${modifier}+Alt+s`)
1163+
await expect(exit).toBeVisible()
1164+
await view.editor.press(`${modifier}+Alt+e`)
1165+
await expect(exit).toHaveCount(0)
1166+
await expect(view.editor).toHaveText("echo hello")
1167+
})
1168+
11361169
for (const row of [
11371170
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
11381171
{ state: "a running", copy: followUp, sessionStatus: { ses_placeholder: { type: "running" } } },

‎packages/app/e2e/regression/new-session.spec.ts‎

Lines changed: 149 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ test("the new session screen does not show session details", async ({ page }) =>
5858
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
5959
})
6060

61-
test("the location shortcut keeps cycling after hovering its icon", async ({ page }) => {
61+
test("the location shortcuts choose worktrees from every state and keep cycling", async ({ page }) => {
6262
const existing = "C:/OpenCode/LocationShortcut/.worktrees/quiet-river"
6363

6464
const view = await openDraft(page, {
@@ -67,27 +67,133 @@ test("the location shortcut keeps cycling after hovering its icon", async ({ pag
6767
})
6868

6969
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
70+
7071
const path = page.locator('[data-slot="new-session-location-path"]')
71-
const icon = page.locator('[data-slot="new-session-location-icon"]')
7272

73-
// The worktree trigger already says Local, so the local path has no icon of its own.
73+
const search = page.getByRole("combobox", { name: "Search worktrees", exact: true })
74+
7475
await expect(path).toHaveText(view.directory)
75-
await expect(icon).toHaveCount(0)
76-
await newSessionWorktree(page).click()
76+
await view.editor.press(`${modifier}+Alt+KeyO`)
77+
await expect(search).toBeFocused()
7778
await page.getByRole("option", { name: /^quiet-river / }).click()
7879
await expect(view.editor).toBeFocused()
7980
await expect(path).toHaveText(existing)
80-
await icon.hover()
81-
await expect(page.getByRole("tooltip")).toContainText("Toggle session location")
82-
await view.editor.press(`${modifier}+Alt+KeyL`)
83-
await expect(newSessionWorktree(page)).toHaveText("Local")
84-
await expect(icon).toHaveCount(0)
85-
await expect(view.editor).toBeFocused()
81+
await expect(page.locator('[data-slot="new-session-location-icon"]')).toHaveCount(0)
82+
await view.editor.press(`${modifier}+Alt+KeyO`)
83+
await expect(search).toBeFocused()
84+
await page.getByRole("option", { name: /LocationShortcut C:\/OpenCode\/LocationShortcut$/ }).click()
8685
await view.editor.press(`${modifier}+Alt+KeyL`)
8786
await expect(newSessionWorktree(page)).toContainText("New worktree")
87+
await view.editor.press(`${modifier}+Alt+KeyO`)
88+
await expect(search).toBeFocused()
89+
await page.getByRole("option", { name: /^quiet-river / }).click()
90+
await expect(path).toHaveText(existing)
8891
await expect(view.editor).toBeFocused()
8992
})
9093

94+
for (const binding of ["custom", "none"]) {
95+
test(`the picker tooltips reflect ${binding} shortcuts`, async ({ page }) => {
96+
const view = await openDraft(page, {
97+
name: "PickerShortcuts",
98+
seed: {
99+
settings: {
100+
keybinds: {
101+
"project.select": binding === "custom" ? "mod+alt+p" : "none",
102+
"session.location.select": binding === "custom" ? "mod+alt+x" : "none",
103+
"session.location.cycle": binding === "custom" ? "mod+alt+k" : "none",
104+
},
105+
},
106+
},
107+
})
108+
109+
const tooltip = page.getByRole("tooltip").and(page.locator("[data-expanded]"))
110+
111+
const keys = tooltip.locator('[data-component="keybind-v2"]')
112+
113+
await newSessionLocation(page).hover()
114+
await expect(tooltip).toContainText("Choose project")
115+
116+
await expect(keys).toHaveText(binding === "custom" ? [/P$/] : [])
117+
await newSessionWorktree(page).hover()
118+
await expect(tooltip).toContainText("Isolate sessions with worktrees")
119+
120+
await expect(keys).toHaveText(binding === "custom" ? [/X$/] : [])
121+
122+
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
123+
124+
if (binding === "none") {
125+
await view.editor.press(`${modifier}+Alt+o`)
126+
await expect(page.getByRole("dialog")).toHaveCount(0)
127+
await expect(newSessionWorktree(page)).toHaveText("Local")
128+
129+
return
130+
}
131+
132+
await view.editor.press(`${modifier}+Alt+k`)
133+
await expect(newSessionWorktree(page)).toContainText("New worktree")
134+
await view.editor.press(`${modifier}+Alt+x`)
135+
const search = page.getByRole("combobox", { name: "Search worktrees", exact: true })
136+
137+
await expect(search).toBeFocused()
138+
await search.press("Escape")
139+
await expect(page.getByRole("dialog")).toHaveCount(0)
140+
await expect(newSessionWorktree(page)).toContainText("New worktree")
141+
await expect(view.editor).toBeFocused()
142+
await view.editor.press(`${modifier}+Alt+p`)
143+
await expect(page.getByRole("combobox", { name: "Search projects", exact: true })).toBeFocused()
144+
})
145+
}
146+
147+
test("the worktree picker shortcut is disabled for non-Git projects", async ({ page }) => {
148+
const view = await openDraft(page, { name: "NoWorktrees", project: { vcs: undefined } })
149+
150+
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
151+
152+
await expect(newSessionWorktree(page)).toHaveCount(0)
153+
await view.editor.press(`${modifier}+Alt+o`)
154+
await expect(page.getByRole("dialog")).toHaveCount(0)
155+
await view.editor.fill("Keep working locally")
156+
await expect(view.editor).toHaveText("Keep working locally")
157+
})
158+
159+
test("picker scrollbars overlay full-width rows and support dragging", async ({ page }) => {
160+
const root = "C:/OpenCode/OverlayMenus"
161+
162+
await openDraft(page, {
163+
name: "OverlayMenus",
164+
worktrees: () => [
165+
{ directory: root },
166+
...Array.from({ length: 24 }, (_, index) => ({
167+
directory: `${root}/worktree-${index}`,
168+
strategy: "git" as const,
169+
})),
170+
],
171+
})
172+
await newSessionWorktree(page).click()
173+
await expect(page.getByRole("combobox", { name: "Search worktrees", exact: true })).toBeFocused()
174+
175+
const menu = page.locator('[data-component="menu-scroll"]')
176+
177+
const viewport = menu.locator('[data-slot="menu-scroll-viewport"]')
178+
179+
const thumb = menu.locator('[data-slot="menu-scroll-thumb"]')
180+
181+
await menu.hover()
182+
await expect(thumb).toHaveCSS("opacity", "1")
183+
await expect.poll(() => viewport.evaluate((el) => el.getBoundingClientRect().width - el.clientWidth)).toBe(0)
184+
185+
const track = (await menu.boundingBox())!
186+
187+
const handle = (await thumb.boundingBox())!
188+
189+
await thumb.hover()
190+
await page.mouse.down()
191+
await page.mouse.move(handle.x + handle.width / 2, track.y + track.height - 4)
192+
await page.mouse.up()
193+
await expect.poll(() => viewport.evaluate((el) => el.scrollTop > 0)).toBe(true)
194+
await expect(page.getByRole("option", { name: /^worktree-23 / })).toBeInViewport()
195+
})
196+
91197
for (const row of [
92198
{
93199
name: "POSIX",
@@ -108,6 +214,21 @@ for (const row of [
108214
await expect.poll(() => path.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true)
109215
await path.hover()
110216
await expect(page.getByRole("tooltip")).toHaveText(row.directory)
217+
218+
if (row.name !== "POSIX") return
219+
await expect(path).toHaveCSS("user-select", "text")
220+
await path.click()
221+
await path.evaluate((element) => {
222+
const range = document.createRange()
223+
224+
range.selectNodeContents(element)
225+
window.getSelection()?.removeAllRanges()
226+
window.getSelection()?.addRange(range)
227+
})
228+
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
229+
230+
await page.keyboard.press(`${modifier}+c`)
231+
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(row.directory)
111232
})
112233
}
113234

@@ -234,11 +355,7 @@ test("the worktree trigger opens the current project's worktree step", async ({
234355
await local.click()
235356
await expect(search).toBeFocused()
236357
await expect(back).toHaveText("Run session in")
237-
await expect(options.locator(".command-palette-title")).toHaveText([
238-
"BranchWorktrees",
239-
"New worktree",
240-
"quiet-river",
241-
])
358+
await expect(options.locator(".command-palette-title")).toHaveText(["BranchWorktrees", "New worktree", "quiet-river"])
242359
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
243360

244361
// Stepping back lands on the projects step with the drilled project active, as after drilling in with →.
@@ -247,15 +364,28 @@ test("the worktree trigger opens the current project's worktree step", async ({
247364
await expect(projects).toBeFocused()
248365
await expect(back).toHaveCount(0)
249366
await expect(options.filter({ hasText: "BranchWorktrees" })).toHaveAttribute("aria-selected", "true")
367+
await projects.press("ArrowRight")
368+
await expect(search).toBeFocused()
369+
await search.press("Escape")
370+
await expect(projects).toBeFocused()
250371
await projects.press("Escape")
251372
await expect(dialog).toHaveCount(0)
252373
await expect(view.editor).toBeFocused()
253374

254-
// Escape also steps back first, then closes.
375+
// A directly opened worktree picker closes on Escape without showing projects first.
255376
await local.focus()
256377
await page.keyboard.press("Enter")
257378
await expect(search).toBeFocused()
258379
await search.press("Escape")
380+
await expect(dialog).toHaveCount(0)
381+
await expect(view.editor).toBeFocused()
382+
383+
// Drilling in from the project picker still makes Escape step back.
384+
await newSessionLocation(page).click()
385+
await expect(projects).toBeFocused()
386+
await projects.press("ArrowRight")
387+
await expect(search).toBeFocused()
388+
await search.press("Escape")
259389
await expect(projects).toBeFocused()
260390
await projects.press("Escape")
261391
await expect(dialog).toHaveCount(0)
@@ -277,15 +407,15 @@ test("the branch shortcut chooses a base branch and stays inactive on Local", as
277407

278408
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
279409

280-
const shortcut = `${modifier}+Alt+Period`
410+
const shortcut = `${modifier}+Alt+KeyB`
281411

282412
const base = page.getByRole("button", { name: "from dev", exact: true })
283413

284414
const search = page.getByRole("combobox", { name: "Search branches", exact: true })
285415

286416
await base.hover()
287417
await expect(page.getByRole("tooltip")).toContainText("Choose base branch")
288-
await expect(page.getByRole("tooltip").locator('[data-component="keybind-v2"]')).toContainText(".")
418+
await expect(page.getByRole("tooltip").locator('[data-component="keybind-v2"]')).toContainText("B")
289419
await expect(view.editor).toBeFocused()
290420
await view.editor.press(shortcut)
291421
await expect(search).toBeFocused()

‎packages/app/src/composer/model.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,19 +579,25 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
579579
disabled: controller.state.mode !== "normal",
580580
onSelect: () => controller.attach(),
581581
},
582+
{
583+
id: "prompt.mode.toggle",
584+
title: language.t("command.prompt.mode.toggle"),
585+
category: language.t("command.category.session"),
586+
keybind: "mod+shift+x",
587+
onSelect: () =>
588+
void controller.dispatch({ type: controller.state.mode === "normal" ? "mode.shell" : "mode.normal" }),
589+
},
582590
{
583591
id: "prompt.mode.shell",
584592
title: language.t("command.prompt.mode.shell"),
585593
category: language.t("command.category.session"),
586-
keybind: "mod+shift+x",
587594
disabled: controller.state.mode === "shell",
588595
onSelect: () => void controller.dispatch({ type: "mode.shell" }),
589596
},
590597
{
591598
id: "prompt.mode.normal",
592599
title: language.t("command.prompt.mode.normal"),
593600
category: language.t("command.category.session"),
594-
keybind: "mod+shift+e",
595601
disabled: controller.state.mode === "normal",
596602
onSelect: () => void controller.dispatch({ type: "mode.normal" }),
597603
},

‎packages/app/src/new-session/location/dialog.css‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,6 @@
111111
color: var(--v2-text-text-faint);
112112
}
113113

114-
.new-session-location-workspace .command-palette-row-icon {
115-
color: var(--v2-icon-icon-base);
116-
}
117-
118114
.new-session-location-workspace .command-palette-row-text > .command-palette-title {
119115
max-width: 60%;
120116
flex-shrink: 0;

0 commit comments

Comments
 (0)