diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 9fc49c0a4196..bee54277796c 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -7,12 +7,11 @@ import { Match, onMount, Show, - startTransition, Switch, untrack, } from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -20,6 +19,8 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" import { usePlatform } from "@/context/platform" @@ -253,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { const serverSync = useServerSync() const navigate = useNavigate() - const homeMatch = useMatch(() => "/") const layout = useLayout() const newSessionHref = () => { @@ -268,17 +268,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs - const navigateTab = (tab: Tab) => { - const href = tabHref(tab) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) - } const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -309,7 +298,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() if (!tabs.ready()) return const tab = currentTab() - if (tab) return + if (tab) { + tabs.remember(tab) + return + } if (route.type === "session") { const sync = serverSync.createDirSyncContext(route.dir) @@ -332,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }) const openNewTab = () => navigate(newSessionHref()) + const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) + + command.register("titlebar-home", () => [ + { + id: "home.toggle", + title: language.t("home.title"), + category: language.t("command.category.view"), + keybind: "mod+b", + hidden: true, + onSelect: toggleHome, + }, + ]) command.register("tabs", () => { const current = currentTab() @@ -369,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === -1) index = tabsStore.length - 1 const next = tabsStore[index] - if (next) navigateTab(next) + if (next) tabs.select(next) }, }, { @@ -386,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === tabsStore.length) index = 0 const next = tabsStore[index] - if (next) navigateTab(next) + if (next) tabs.select(next) }, }, ...Array.from({ length: 9 }, (_, i) => { @@ -401,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { hidden: true, onSelect: () => { const tab = tabsStore[index] - if (tab) navigateTab(tab) + if (tab) tabs.select(tab) }, } }), @@ -427,15 +431,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { - } - state={!!homeMatch() ? "pressed" : undefined} - /> + + {language.t("home.title")} + + + } + class="shrink-0" + > + } + state={layout.route().type === "home" ? "pressed" : undefined} + onClick={toggleHome} + aria-label={language.t("home.title")} + aria-pressed={layout.route().type === "home"} + /> +
{ - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} onClose={() => tabsStoreActions.removeTab(i())} @@ -488,7 +505,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { directory={decode64(tab.dirBase64)!} sessionId={tab.sessionId} onNavigate={() => { - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} @@ -518,7 +535,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { title={language.t("command.session.new")} onClose={() => { const tab = tabsStore.at(-1) - if (tab) navigateTab(tab) + if (tab) tabs.select(tab) else navigate("/") }} /> diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e979ad6a0595..16483706b8e9 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean return false } -export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { - if (!config || config === "none") return "" - - const keybinds = parseKeybind(config) - if (keybinds.length === 0) return "" - - const kb = keybinds[0] +function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { const parts: string[] = [] if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) @@ -184,40 +178,52 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t)) - if (kb.key) { - const keys: Record = { - arrowup: "↑", - arrowdown: "↓", - arrowleft: "←", - arrowright: "→", - comma: ",", - plus: "+", - } - const named: Record = { - 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 displayKey = - keys[key] ?? - (named[key] - ? keyText(named[key], t) - : key.length === 1 - ? key.toUpperCase() - : key.charAt(0).toUpperCase() + key.slice(1)) - parts.push(displayKey) + if (!kb.key) return parts + + const keys: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + comma: ",", + plus: "+", + } + const named: Record = { + 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 displayKey = + keys[key] ?? + (named[key] + ? keyText(named[key], t) + : key.length === 1 + ? key.toUpperCase() + : key.charAt(0).toUpperCase() + key.slice(1)) + parts.push(displayKey) + + return parts +} + +export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] { + if (!config || config === "none") return [] + const keybind = parseKeybind(config)[0] + return keybind ? displayKeybindParts(keybind, t) : [] +} +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { + const parts = formatKeybindParts(config, t) + if (parts.length === 0) return "" return IS_MAC ? parts.join("") : parts.join("+") } @@ -402,25 +408,26 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) } + const keybindConfig = (id: string) => { + if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + const base = actionId(id) + return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind) + } + return { register, trigger(id: string, source?: CommandSource) { run(id, source) }, keybind(id: string) { - if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t) - } - - const base = actionId(id) - const option = options().find((x) => actionId(x.id) === base) - if (option?.keybind) return formatKeybind(option.keybind, language.t) - - const meta = catalog[base] - const config = bind(base, meta?.keybind) + const config = keybindConfig(id) if (!config) return "" return formatKeybind(config, language.t) }, + keybindParts(id: string) { + const config = keybindConfig(id) + return config ? formatKeybindParts(config, language.t) : [] + }, show: showPalette, keybinds(enabled: boolean) { setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 7f6274cf9922..393875ff0915 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -27,6 +27,10 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab +type RecentTab = { + key?: string +} + export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => @@ -62,26 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, createStore([]), ) + const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore({})) const params = useParams() const navigate = useNavigate() const location = useLocation() const closing = new Set() + let recentWrite = 0 + let recentValue: string | undefined + + const recentKey = () => (recentWrite ? recentValue : recent.key) + + const setRecentKey = (key: string | undefined) => { + const write = ++recentWrite + recentValue = key + if (recentReady()) { + setRecent("key", key) + return + } + void recentReady.promise?.then(() => { + if (write === recentWrite) setRecent("key", key) + }) + } const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } createEffect(() => { - if (!ready()) return + if (!ready() || !recentReady()) return const servers = new Set(server.list.map(ServerConnection.key)) - if (store.every((tab) => servers.has(tab.server))) return - setStore((tabs) => tabs.filter((tab) => servers.has(tab.server))) + const next = store.filter((tab) => servers.has(tab.server)) + if (next.length !== store.length) setStore(() => next) + if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) const navigateTab = (tab: Tab) => { const href = tabHref(tab) + setRecentKey(tabKey(tab)) if (tab.server === server.key) { navigate(href) return @@ -129,14 +152,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID + const next = { type: "session" as const, ...session } startTransition(() => { setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index !== -1) tabs[index] = { type: "session", ...session } + if (index !== -1) tabs[index] = next }), ) - if (active) navigateTab({ type: "session", ...session }) + if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) + if (active) navigateTab(next) }) removeDraftPersisted(draftID) }, @@ -153,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ tabs.splice(index, 1) }), ) + if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) @@ -160,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, removeServer(key: ServerConnection.Key) { const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) + const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { + const removed = store + .filter( + (tab) => + tab.type === "session" && + tab.server === server.key && + atob(tab.dirBase64) === input.directory && + input.sessionIDs.includes(tab.sessionId), + ) + .map(tabKey) void startTransition(() => { setStore( produce((tabs) => { @@ -207,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ else navigate("/") }), ) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) }, + select: navigateTab, + remember(tab: Tab) { + const key = tabKey(tab) + if (recentKey() !== key) setRecentKey(key) + }, + toggleHome(input: { home: boolean; current?: Tab }) { + if (input.home) { + const tab = store.find((tab) => tabKey(tab) === recentKey()) + if (tab) navigateTab(tab) + return + } + if (input.current) { + setRecentKey(tabKey(input.current)) + navigate("/") + return + } + navigate("/") + }, } - return { ...actions, store, ready } + return { ...actions, store, ready, recentReady } }, }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 677738fde90a..d3902cd2b245 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ id: "view", label: "View", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "separator" }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 199db2fda66c..5eb01989cef3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -996,7 +996,7 @@ export default function Layout(props: ParentProps) { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), - keybind: "mod+b", + keybind: newDesign() ? undefined : "mod+b", onSelect: () => layout.sidebar.toggle(), }, { diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index e75e4c7aded1..c4a3df9115ec 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -137,6 +137,7 @@ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { background-color: var(--v2-overlay-simple-overlay-pressed); + color: var(--v2-icon-icon-base); } [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {