Explorar el Código

feat(app): add v2 home tab toggle (#32191)

Luke Parker hace 2 meses
padre
commit
5c9e4ff21b

+ 47 - 30
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 }) {
                 <Show when={windows() || linux()}>
                   <WindowsAppMenu command={command} platform={platform} variant="v2" />
                 </Show>
-                <IconButtonV2
-                  variant="ghost-muted"
-                  size="large"
-                  as="a"
-                  href="/"
-                  class="!w-9 shrink-0"
-                  icon={<IconV2 name="grid-plus" />}
-                  state={!!homeMatch() ? "pressed" : undefined}
-                />
+                <TooltipV2
+                  placement="bottom"
+                  value={
+                    <>
+                      {language.t("home.title")}
+                      <KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
+                    </>
+                  }
+                  class="shrink-0"
+                >
+                  <IconButtonV2
+                    type="button"
+                    variant="ghost-muted"
+                    size="large"
+                    class="!w-9 shrink-0"
+                    icon={<IconV2 name="grid-plus" />}
+                    state={layout.route().type === "home" ? "pressed" : undefined}
+                    onClick={toggleHome}
+                    aria-label={language.t("home.title")}
+                    aria-pressed={layout.route().type === "home"}
+                  />
+                </TooltipV2>
 
                 <div data-slot="titlebar-tabs" class="relative min-w-0">
                   <div
@@ -469,7 +486,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                                   title={language.t("command.session.new")}
                                   active={currentTab() === tab}
                                   onNavigate={() => {
-                                    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("/")
                                 }}
                               />

+ 56 - 49
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<string, string> = {
-      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 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<string, string> = {
+    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 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)))

+ 62 - 6
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<Tab[]>([]),
     )
+    const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
 
     const params = useParams()
     const navigate = useNavigate()
     const location = useLocation()
 
     const closing = new Set<string>()
+    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 }
   },
 })

+ 1 - 1
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" },

+ 1 - 1
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(),
       },
       {

+ 1 - 0
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"]) {