mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): rename the current session with ctrl+r (#57423)
This commit is contained in:
committed by
GitHub
Unverified
parent
5c95abcf34
commit
71a1b10ba4
@@ -1,6 +1,6 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { Command } from "@opencode/gui-extensions/sdk"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useCommand, type CommandSource } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { migrateKeybinds } from "@/settings/keybinds/migration"
|
||||
import { useExtensionHost } from "./host"
|
||||
@@ -33,13 +33,22 @@ export function ExtensionCommands() {
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: item.value.scope
|
||||
? (event: KeyboardEvent) =>
|
||||
event.target instanceof Element && !!event.target.closest(item.value.scope as string)
|
||||
: undefined,
|
||||
onSelect: (_source: unknown, input?: string) => item.value.run(input),
|
||||
when: item.value.scope ? scopedTo(item.value.scope) : undefined,
|
||||
onSelect: (_source?: CommandSource, input?: string) => item.value.run(input),
|
||||
})),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const scopedTo = (scope: string) => (event: KeyboardEvent) =>
|
||||
event.target instanceof Element && !!event.target.closest(scope)
|
||||
|
||||
/** Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there. */
|
||||
export function useExtensionScoped() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return (target: EventTarget | null) =>
|
||||
target instanceof Element &&
|
||||
host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope))
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export const dict = {
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
"command.session.import": "Import session",
|
||||
"command.session.copyID": "Copy Session ID",
|
||||
"command.session.rename": "Rename session",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
|
||||
@@ -15,8 +15,7 @@ import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import type { SessionRevert } from "@/session/revert"
|
||||
import { Command } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionScoped } from "@/runtime/extension/commands"
|
||||
import type { Region } from "@/runtime/extension/panels"
|
||||
import { createSideFocus } from "@/runtime/extension/closed-tabs"
|
||||
|
||||
@@ -60,7 +59,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const host = useExtensionHost()
|
||||
const extensionScoped = useExtensionScoped()
|
||||
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = actions.session.ownership.capture()
|
||||
@@ -85,11 +84,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const inSide = createSideFocus()
|
||||
const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1
|
||||
|
||||
// Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there.
|
||||
const extensionScoped = (target: EventTarget | null) =>
|
||||
target instanceof Element &&
|
||||
host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope))
|
||||
|
||||
const sideFocused = (target: EventTarget | null) => {
|
||||
const element =
|
||||
target instanceof Element && target !== document.body && target !== document.documentElement
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { parseVoiceDelegation } from "./voice-delegation"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useExtensionScoped } from "@/runtime/extension/commands"
|
||||
import {
|
||||
SessionAncestorTrail,
|
||||
SessionHeaderLayout,
|
||||
@@ -127,6 +128,8 @@ function MessageTimelineView(
|
||||
},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const extensionScoped = useExtensionScoped()
|
||||
const server = useServer()
|
||||
const data = server.ctx.data
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -288,6 +291,22 @@ function MessageTimelineView(
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
command.register(() =>
|
||||
props.active === false || props.hideHeader
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "session.rename",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.rename"),
|
||||
keybind: "ctrl+r",
|
||||
disabled: !sessionID() || !!parentID() || title.editing,
|
||||
skip: (event) => extensionScoped(event.target),
|
||||
onSelect: openTitleEditor,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (props.active !== false) return
|
||||
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
addCommandRegistration,
|
||||
commandPaletteOptions,
|
||||
CommandCatalog,
|
||||
commandKeymap,
|
||||
formatKeybind,
|
||||
matchKeybind,
|
||||
parseKeybind,
|
||||
@@ -68,6 +69,26 @@ test.each([true, false])("resolveKeybindOption prefers a contextual command only
|
||||
)
|
||||
})
|
||||
|
||||
test("commandKeymap puts a user's binding before a default on the same keys", () => {
|
||||
const rename = { id: "session.rename", title: "Rename session", keybind: "ctrl+r" }
|
||||
const focus = { id: "input.focus", title: "Focus input", keybind: "ctrl+r" }
|
||||
const event = new KeyboardEvent("keydown", { key: "r", ctrlKey: true })
|
||||
const keymap = (custom: string[]) => commandKeymap([rename, focus], (id) => custom.includes(id)).get("r:1")
|
||||
|
||||
expect(resolveKeybindOption(keymap([]), event)).toBe(rename)
|
||||
expect(resolveKeybindOption(keymap(["input.focus"]), event)).toBe(focus)
|
||||
expect(commandKeymap([{ ...rename, disabled: true }], () => false).size).toBe(0)
|
||||
})
|
||||
|
||||
test.each([true, false])("resolveKeybindOption leaves the key to a skipping command's target: %p", (inside) => {
|
||||
const rename = { id: "session.rename", title: "Rename session", skip: () => inside }
|
||||
const focus = { id: "input.focus", title: "Focus input" }
|
||||
const event = new KeyboardEvent("keydown")
|
||||
|
||||
expect(resolveKeybindOption([rename], event)).toBe(inside ? undefined : rename)
|
||||
expect(resolveKeybindOption([rename, focus], event)).toBe(inside ? focus : rename)
|
||||
})
|
||||
|
||||
describe("command keybinds", () => {
|
||||
test("parseKeybind handles aliases, multiple combos, and disabled bindings", () => {
|
||||
const keybinds = parseKeybind("control+option+k, mod+shift+comma")
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { Predicate, Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -11,7 +11,7 @@ import { keybindRenames } from "@/settings/keybinds/migration"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const IS_MAC = /(Mac|iPod|iPhone|iPad)/.test(globalThis.navigator?.platform ?? "")
|
||||
|
||||
const PALETTE_ID = "command.palette"
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface CommandOption {
|
||||
/** The keybind also fires while a text field has focus. */
|
||||
editable?: boolean
|
||||
when?: (event: KeyboardEvent) => boolean
|
||||
/** The keybind leaves this event to its target, as if the command had no keybind. */
|
||||
skip?: (event: KeyboardEvent) => boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
@@ -123,11 +125,38 @@ export function commandPaletteOptions(options: CommandOption[]) {
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
||||
/** Maps each keybind signature to its commands. A user's own binding comes before a default on the same keys. */
|
||||
export function commandKeymap(options: CommandOption[], custom: (id: string) => boolean) {
|
||||
const map = new Map<string, CommandOption[]>()
|
||||
|
||||
for (const option of options) {
|
||||
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
|
||||
|
||||
if (option.disabled) continue
|
||||
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
|
||||
for (const kb of keybinds) {
|
||||
if (!kb.key) continue
|
||||
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
|
||||
const existing = map.get(sig) ?? []
|
||||
|
||||
map.set(sig, custom(actionId(option.id)) ? [option, ...existing] : [...existing, option])
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
type CommandSource = "palette" | "keybind" | "slash"
|
||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||
const live = candidates?.filter((option) => !option.skip?.(event))
|
||||
|
||||
return live?.find((option) => option.when?.(event)) ?? live?.find((option) => !option.when)
|
||||
}
|
||||
|
||||
export type CommandSource = "palette" | "keybind" | "slash"
|
||||
|
||||
export const CommandCatalogItem = Persistence.struct({
|
||||
title: Schema.String,
|
||||
@@ -264,39 +293,37 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
|
||||
|
||||
if (!kb.key) return parts
|
||||
|
||||
const keys: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
}
|
||||
const keys = new Map([
|
||||
["arrowup", "↑"],
|
||||
["arrowdown", "↓"],
|
||||
["arrowleft", "←"],
|
||||
["arrowright", "→"],
|
||||
["comma", ","],
|
||||
["plus", "+"],
|
||||
])
|
||||
|
||||
const named: Record<string, KeyLabel> = {
|
||||
backspace: "common.key.backspace",
|
||||
delete: "common.key.delete",
|
||||
end: "common.key.end",
|
||||
enter: "common.key.enter",
|
||||
esc: "common.key.esc",
|
||||
escape: "common.key.esc",
|
||||
home: "common.key.home",
|
||||
insert: "common.key.insert",
|
||||
pagedown: "common.key.pageDown",
|
||||
pageup: "common.key.pageUp",
|
||||
space: "common.key.space",
|
||||
tab: "common.key.tab",
|
||||
}
|
||||
const named = new Map<string, KeyLabel>([
|
||||
["backspace", "common.key.backspace"],
|
||||
["delete", "common.key.delete"],
|
||||
["end", "common.key.end"],
|
||||
["enter", "common.key.enter"],
|
||||
["esc", "common.key.esc"],
|
||||
["escape", "common.key.esc"],
|
||||
["home", "common.key.home"],
|
||||
["insert", "common.key.insert"],
|
||||
["pagedown", "common.key.pageDown"],
|
||||
["pageup", "common.key.pageUp"],
|
||||
["space", "common.key.space"],
|
||||
["tab", "common.key.tab"],
|
||||
])
|
||||
|
||||
const key = kb.key.toLowerCase()
|
||||
|
||||
const label = named.get(key)
|
||||
|
||||
const displayKey =
|
||||
keys[key] ??
|
||||
(named[key]
|
||||
? keyText(named[key], t)
|
||||
: key.length === 1
|
||||
? key.toUpperCase()
|
||||
: key.charAt(0).toUpperCase() + key.slice(1))
|
||||
keys.get(key) ??
|
||||
(label ? keyText(label, t) : key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
||||
|
||||
parts.push(displayKey)
|
||||
|
||||
@@ -337,8 +364,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
registrations: [] as CommandRegistration[],
|
||||
const [store, setStore] = createStore<{ registrations: CommandRegistration[]; suspendCount: number }>({
|
||||
registrations: [],
|
||||
suspendCount: 0,
|
||||
})
|
||||
|
||||
@@ -437,34 +464,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
|
||||
})
|
||||
|
||||
const keymap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption[]>()
|
||||
|
||||
for (const option of options()) {
|
||||
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
|
||||
|
||||
if (option.disabled) continue
|
||||
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
|
||||
for (const kb of keybinds) {
|
||||
if (!kb.key) continue
|
||||
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
|
||||
const existing = map.get(sig)
|
||||
|
||||
if (existing) {
|
||||
existing.push(option)
|
||||
continue
|
||||
}
|
||||
|
||||
map.set(sig, [option])
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
const keymap = createMemo(() => commandKeymap(options(), (id) => settings.keybinds.get(id) !== undefined))
|
||||
|
||||
const optionMap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption>()
|
||||
@@ -519,8 +519,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
function register(cb: () => CommandOption[]): void
|
||||
function register(key: string, cb: () => CommandOption[]): void
|
||||
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
|
||||
const id = typeof key === "string" ? key : undefined
|
||||
const next = typeof key === "function" ? key : cb
|
||||
const id = Predicate.isString(key) ? key : undefined
|
||||
const next = Predicate.isFunction(key) ? key : cb
|
||||
|
||||
if (!next) return
|
||||
const options = createMemo(next)
|
||||
|
||||
@@ -59,6 +59,11 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
|
||||
const wire = (win: BrowserWindow) => {
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
// Ctrl+R renames the session in the renderer, so the default menu's Reload must not take it.
|
||||
win.webContents.setIgnoreMenuShortcuts(
|
||||
input.control && !input.meta && !input.shift && !input.alt && input.key.toLowerCase() === "r",
|
||||
)
|
||||
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user