diff --git a/packages/app/src/new-session/commands.tsx b/packages/app/src/new-session/commands.tsx index da53f118f165..6b1655dcfd14 100644 --- a/packages/app/src/new-session/commands.tsx +++ b/packages/app/src/new-session/commands.tsx @@ -9,6 +9,10 @@ export function useNewSessionCommands(input: { empty: () => boolean open: () => void } + workspace: { + enabled: () => boolean + cycle: () => void + } }) { const command = useCommand() const dialog = useDialog() @@ -40,5 +44,13 @@ export function useNewSessionCommands(input: { disabled: input.project.empty(), onSelect: input.project.open, }, + { + id: "session.location.cycle", + title: language.t("command.session.location.cycle"), + category: language.t("command.category.workspace"), + keybind: "mod+alt+l", + disabled: !input.workspace.enabled(), + onSelect: input.workspace.cycle, + }, ]) } diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 98712ac9a0dd..1a5ff56df850 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -60,6 +60,10 @@ export default function NewSessionPage(props: { draftId: string }) { empty: project.empty, open: () => project.setOpen(true), }, + workspace: { + enabled: workspace.bar.visible, + cycle: workspace.selection.cycle, + }, }) createEffect(() => { if (!composer.ready()) return diff --git a/packages/app/src/new-session/workspace/controller.test.ts b/packages/app/src/new-session/workspace/controller.test.ts index 44949821938a..6cb365f845a7 100644 --- a/packages/app/src/new-session/workspace/controller.test.ts +++ b/packages/app/src/new-session/workspace/controller.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller" +import { + cycleNewSessionWorktree, + resolveNewSessionBranch, + resolveNewSessionGit, + resolveNewSessionWorktree, +} from "./controller" describe("new session workspace selection", () => { test("uses main when the workspace bar is unavailable", () => { @@ -64,4 +69,16 @@ describe("new session workspace selection", () => { expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true) expect(resolveNewSessionGit({})).toBe(false) }) + + test("cycles between local and a new worktree", () => { + expect(cycleNewSessionWorktree({ current: "main" })).toBe("create") + expect(cycleNewSessionWorktree({ current: "create" })).toBe("main") + }) + + test("includes the selected existing worktree in the cycle", () => { + const existing = "/project/feature" + expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main") + expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create") + expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing) + }) }) diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 316257c71a89..86fe3514dc10 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -37,6 +37,12 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri return input.projectVcs === "git" || input.branch !== undefined } +export function cycleNewSessionWorktree(input: { current: string; existing?: string }) { + if (input.current === "main") return "create" + if (input.current === "create") return input.existing ?? "main" + return "main" +} + export function createNewSessionWorkspaceController(input: { selectedWorktree: () => string | undefined selectedBranch: () => string | undefined @@ -49,7 +55,10 @@ export function createNewSessionWorkspaceController(input: { const data = useData() const settings = useSettings() const tabs = useTabs() - const [state, setState] = createStore({ search: "" }) + const [state, setState] = createStore({ + search: "", + existing: undefined as { projectID: string; directory: string } | undefined, + }) const searchBranches = debounce((search: string) => setState("search", search.trim()), 100) const currentProject = createMemo(() => { const projectID = data.location.info({ directory: sdk().directory })?.project.id @@ -159,6 +168,8 @@ export function createNewSessionWorkspaceController(input: { createEffect(() => { const selection = value() if (selection === "main" || selection === "create") return + const project = currentProject() + if (project) setState("existing", { projectID: project.id, directory: selection }) void data.location.vcs.sync({ directory: selection }).catch(() => undefined) }) const branch = createMemo(() => @@ -176,6 +187,20 @@ export function createNewSessionWorkspaceController(input: { const local = workspaceSelectionDestination(worktree, project.worktree) === "main" settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace") } + const select = (worktree: string) => { + input.setSelectedBranch(undefined) + input.setSelectedWorktree(worktree) + remember(worktree) + } + // The remembered worktree may have been removed since it was selected. Cycling to a directory the + // inventory no longer contains would resolve back to the fallback and leave the cycle stuck. + const existing = () => { + const project = currentProject() + const previous = state.existing + if (!project || previous?.projectID !== project.id) return + if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return + return previous.directory + } return { selection: { @@ -193,11 +218,8 @@ export function createNewSessionWorkspaceController(input: { input.setSelectedBranch(undefined) }, remember, - set: (worktree: string) => { - input.setSelectedBranch(undefined) - input.setSelectedWorktree(worktree) - remember(worktree) - }, + set: select, + cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })), create: (branch: string) => { input.setSelectedBranch(branch) input.setSelectedWorktree("create") diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 87a7650fd7b0..c82892415a7f 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.previous.unseen": "Previous unread session", "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", + "command.session.location.cycle": "Cycle session location", "command.palette": "Command palette", diff --git a/packages/app/src/settings/keybinds/keybinds.tsx b/packages/app/src/settings/keybinds/keybinds.tsx index 901a82980014..3db42134cf35 100644 --- a/packages/app/src/settings/keybinds/keybinds.tsx +++ b/packages/app/src/settings/keybinds/keybinds.tsx @@ -6,7 +6,13 @@ import { IconButton } from "@opencode/ui/icon-button" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" import fuzzysort from "fuzzysort" -import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/shell/commands/command" +import { + DEFAULT_PALETTE_KEYBIND, + formatKeybind, + keyFromKeyboardEvent, + parseKeybind, + useCommand, +} from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSettings } from "@/settings/model" import { SettingsList } from "@/settings/list" @@ -69,13 +75,6 @@ function isModifier(key: string) { return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta" } -function normalizeKey(key: string) { - if (key === ",") return "comma" - if (key === "+") return "plus" - if (key === " ") return "space" - return key.toLowerCase() -} - function recordKeybind(event: KeyboardEvent) { if (isModifier(event.key)) return @@ -89,7 +88,7 @@ function recordKeybind(event: KeyboardEvent) { if (event.altKey) parts.push("alt") if (event.shiftKey) parts.push("shift") - const key = normalizeKey(event.key) + const key = keyFromKeyboardEvent(event) if (!key) return parts.push(key) diff --git a/packages/app/src/shell/commands/command-keybind.test.ts b/packages/app/src/shell/commands/command-keybind.test.ts index c8e2dbb5d0f5..d6ea80df0f1f 100644 --- a/packages/app/src/shell/commands/command-keybind.test.ts +++ b/packages/app/src/shell/commands/command-keybind.test.ts @@ -51,6 +51,15 @@ describe("command keybind helpers", () => { ).toBe(true) }) + test("matchKeybind uses the letter key for option-modified characters", () => { + expect( + matchKeybind( + parseKeybind("meta+alt+l"), + new KeyboardEvent("keydown", { key: "¬", code: "KeyL", metaKey: true, altKey: true }), + ), + ).toBe(true) + }) + test("formatKeybind returns human readable output", () => { const display = formatKeybind("ctrl+alt+arrowup") diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index b7a46df96ee5..4ce001a1a35e 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -50,13 +50,20 @@ function normalizeKey(key: string) { return key.toLowerCase() } +export function keyFromKeyboardEvent(event: KeyboardEvent) { + const key = normalizeKey(event.key) + if (!event.altKey || /^[a-z0-9]$/.test(key)) return key + if (!event.code.startsWith("Key") || event.code.length !== 4) return key + return event.code.slice(3).toLowerCase() +} + function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) { const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0) return `${key}:${mask}` } function signatureFromEvent(event: KeyboardEvent) { - return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) + return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) } function isAllowedEditableKeybind(id: string | undefined) { @@ -179,7 +186,7 @@ export function parseKeybind(config: string): Keybind[] { } export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { - const eventKey = normalizeKey(event.key) + const eventKey = keyFromKeyboardEvent(event) for (const kb of keybinds) { const keyMatch = kb.key === eventKey