Sfoglia il codice sorgente

fix(tui): show current location in working copies; order by created; change shortcut; tab to cycle actions in dialog select (#30989)

James Long 3 mesi fa
parent
commit
ecdfcd91ca

+ 3 - 4
packages/core/src/project.ts

@@ -2,7 +2,7 @@ export * as ProjectV2 from "./project"
 export * as Project from "./project"
 
 import { Context, Effect, Layer, Schema } from "effect"
-import { eq } from "drizzle-orm"
+import { asc, desc, eq } from "drizzle-orm"
 import path from "path"
 import { AbsolutePath, withStatics } from "./schema"
 import { FSUtil } from "./fs-util"
@@ -76,11 +76,10 @@ export const layer = Layer.effect(
         .select({ directory: ProjectDirectoryTable.directory })
         .from(ProjectDirectoryTable)
         .where(eq(ProjectDirectoryTable.project_id, input.projectID))
+        .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
         .all()
         .pipe(Effect.orDie)
-      return rows
-        .toSorted((a, b) => a.directory.localeCompare(b.directory))
-        .map((row) => AbsolutePath.make(row.directory))
+      return rows.map((row) => AbsolutePath.make(row.directory))
     })
 
     const cached = Effect.fnUntraced(function* (dir: string) {

+ 5 - 5
packages/core/test/project.test.ts

@@ -65,7 +65,7 @@ describe("Project directories schemas", () => {
     }),
   )
 
-  it.effect("lists stored project directories only for the requested project", () =>
+  it.effect("lists stored project directories newest first for the requested project", () =>
     Effect.gen(function* () {
       const project = yield* ProjectV2.Service
       const { db } = yield* Database.Service
@@ -82,16 +82,16 @@ describe("Project directories schemas", () => {
       yield* db
         .insert(ProjectDirectoryTable)
         .values([
-          { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
-          { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
-          { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
+          { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 },
+          { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
+          { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
         ])
         .run()
         .pipe(Effect.orDie)
 
       expect(yield* project.directories({ projectID })).toEqual([
-        AbsolutePath.make("/repo/a"),
         AbsolutePath.make("/repo/z"),
+        AbsolutePath.make("/repo/a"),
       ])
     }),
   )

+ 9 - 4
packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx

@@ -15,7 +15,11 @@ const REFRESH_FRAMES = ["■", "⬝"]
 
 export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
 
-export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) {
+export function DialogMoveSession(props: {
+  projectID: string
+  current?: MoveSessionSelection
+  onSelect: (selection: MoveSessionSelection) => void
+}) {
   const dialog = useDialog()
   const sdk = useSDK()
   const dimensions = useTerminalDimensions()
@@ -42,7 +46,7 @@ export function DialogMoveSession(props: { projectID: string; onSelect: (selecti
     },
   )
 
-  const options = createMemo<DialogSelectOption<string | undefined>[]>(() => {
+  const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
     if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
     if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
     const data = directories()
@@ -88,7 +92,7 @@ export function DialogMoveSession(props: { projectID: string; onSelect: (selecti
             <span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
           </>
         ) : undefined,
-        value: item.location,
+        value: { type: "directory", directory: item.location } as const,
         category: item.root === data?.main ? "Project" : "Working copies",
         titleWidth,
         truncateTitle: "left" as const,
@@ -103,8 +107,9 @@ export function DialogMoveSession(props: { projectID: string; onSelect: (selecti
       <DialogSelect
         title="Move session"
         options={options()}
+        current={props.current}
         onSelect={(option) => {
-          if (option.value) props.onSelect({ type: "directory", directory: option.value })
+          if (option.value) props.onSelect(option.value)
         }}
         actions={[
           {

+ 3 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx

@@ -51,9 +51,12 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
   function open() {
     const projectID = input.projectID()
     if (!projectID) return
+    const sessionID = input.sessionID()
+    const session = sessionID ? sync.session.get(sessionID) : undefined
     dialog.replace(() => (
       <DialogMoveSession
         projectID={projectID}
+        current={homeDestination?.destination() ?? (session ? { type: "directory", directory: session.directory } : undefined)}
         onSelect={(selection) => {
           const sessionID = input.sessionID()
           if (!sessionID) {

+ 1 - 1
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -207,7 +207,7 @@ export const Definitions = {
   "dialog.select.submit": keybind("return", "Submit selected dialog item"),
   "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
   "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
-  "dialog.move_session.new": keybind("ctrl+w", "New project copy"),
+  "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
   "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
   "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
   "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),

+ 4 - 5
packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx

@@ -11,11 +11,10 @@ function Directory(props: { api: TuiPluginApi }) {
   const destination = useHomeSessionDestination()
   const dir = createMemo(() => {
     const selected = destination?.destination()
-    if (selected?.type === "new") return
-    if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~")
-    const dir = props.api.state.path.directory || process.cwd()
-    const out = dir.replace(Global.Path.home, "~")
-    const branch = props.api.state.vcs?.branch
+    if (!selected || selected.type === "new") return
+    const out = selected.directory.replace(Global.Path.home, "~")
+    const branch =
+      selected.directory === (props.api.state.path.directory || process.cwd()) ? props.api.state.vcs?.branch : undefined
     if (branch) return out + ":" + branch
     return out
   })

+ 7 - 2
packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx

@@ -1,4 +1,5 @@
-import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
+import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
+import { useSync } from "../../context/sync"
 
 export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
 
@@ -11,7 +12,11 @@ type Context = {
 const HomeSessionDestinationContext = createContext<Context>()
 
 export function HomeSessionDestinationProvider(props: ParentProps) {
-  const [destination, setDestination] = createSignal<HomeSessionDestination>()
+  const sync = useSync()
+  const [selected, setDestination] = createSignal<HomeSessionDestination>()
+  const destination = createMemo<HomeSessionDestination>(() =>
+    selected() ?? { type: "directory", directory: sync.path.directory || process.cwd() },
+  )
   return (
     <HomeSessionDestinationContext.Provider
       value={{ destination, setDestination, clear: () => setDestination(undefined) }}

+ 119 - 30
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -9,7 +9,7 @@ import {
 import type { Binding } from "@opentui/keymap"
 import { useTheme, selectedForeground } from "@tui/context/theme"
 import { entries, filter, flatMap, groupBy, pipe } from "remeda"
-import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
+import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useTerminalDimensions } from "@opentui/solid"
 import * as fuzzysort from "fuzzysort"
@@ -74,6 +74,10 @@ export type DialogSelectRef<T> = {
 }
 
 export function DialogSelect<T>(props: DialogSelectProps<T>) {
+  type Action = NonNullable<DialogSelectProps<T>["actions"]>[number]
+  type FooterHint = NonNullable<DialogSelectProps<T>["footerHints"]>[number]
+  type VisibleAction = (Action & { label: string }) | FooterHint
+
   const dialog = useDialog()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
@@ -84,6 +88,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     filter: "",
     input: "keyboard" as "keyboard" | "mouse",
   })
+  const [focusedAction, setFocusedAction] = createSignal<number>()
+  const actionFocused = createMemo(() => focusedAction() !== undefined)
 
   createEffect(
     on(
@@ -119,6 +125,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
     return labels
   })
+  const visibleActions = createMemo(() => [
+    ...actions()
+      .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
+      .filter((item) => !item.disabled && item.label),
+    ...(props.footerHints ?? []),
+  ])
+  const actionItems = createMemo(() => visibleActions().filter(isActionItem))
+
+  createEffect(() => {
+    const index = focusedAction()
+    if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
+  })
 
   const filtered = createMemo(() => {
     if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
@@ -147,6 +165,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   createEffect(() => {
     filtered()
     setStore("input", "keyboard")
+    setFocusedAction(undefined)
   })
 
   const flatten = createMemo(() => props.flat && store.filter.length > 0)
@@ -206,6 +225,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
 
   function moveTo(next: number, center = false) {
+    setFocusedAction(undefined)
     setStore("selected", next)
     const option = selected()
     if (option) props.onMove?.(option)
@@ -233,12 +253,27 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
   function submit() {
     setStore("input", "keyboard")
+    const index = focusedAction()
+    if (index !== undefined) {
+      triggerAction(actionItems()[index])
+      return
+    }
     const option = selected()
     if (!option) return
     option.onSelect?.(dialog)
     props.onSelect?.(option)
   }
 
+  function moveAction(direction: 1 | -1) {
+    const total = actionItems().length
+    if (total === 0) return
+    setFocusedAction((index) => {
+      if (index === undefined) return direction === 1 ? 0 : total - 1
+      const next = index + direction
+      return next < 0 || next >= total ? undefined : next
+    })
+  }
+
   useBindings(() => {
     const enabledActions = actions().filter((item) => !item.disabled)
 
@@ -327,6 +362,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           "dialog.select.submit",
         ]),
         ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
+        ...(enabledActions.length
+          ? [
+              {
+                key: "tab",
+                desc: "Next dialog action",
+                group: "Dialog",
+                cmd: () => moveAction(1),
+              },
+              {
+                key: "shift+tab",
+                desc: "Previous dialog action",
+                group: "Dialog",
+                cmd: () => moveAction(-1),
+              },
+            ]
+          : []),
         ...(props.bindings ?? []).filter((binding) => {
           if (typeof binding.cmd !== "string") return true
           return enabledActions.some((item) => item.command === binding.cmd)
@@ -353,15 +404,53 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
   props.ref?.(ref)
 
-  const visibleActions = createMemo(() => [
-    ...actions()
-      .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
-      .filter((item) => !item.disabled && item.label),
-    ...(props.footerHints ?? []),
-  ])
   const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
   const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
 
+  function triggerAction(item: VisibleAction | undefined) {
+    if (!item || !isActionItem(item)) return
+    setStore("input", "keyboard")
+    const option = selected()
+    if (!option) return
+    item.onTrigger(option)
+  }
+
+  function isActionItem(item: VisibleAction): item is Action & { label: string } {
+    return "onTrigger" in item
+  }
+
+  function isActionFocused(item: VisibleAction) {
+    if (!isActionItem(item)) return false
+    return actionItems().indexOf(item) === focusedAction()
+  }
+
+  function FooterAction(action: { item: VisibleAction }) {
+    if (!isActionItem(action.item))
+      return (
+        <text>
+          <span style={{ fg: theme.text }}>
+            <b>{action.item.title}</b>{" "}
+          </span>
+          <span style={{ fg: theme.textMuted }}>{action.item.label}</span>
+        </text>
+      )
+    const active = createMemo(() => isActionFocused(action.item))
+    const fg = selectedForeground(theme)
+    return (
+      <box
+        flexDirection="row"
+        paddingRight={1}
+        backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+        onMouseUp={() => triggerAction(action.item)}
+      >
+        <text fg={active() ? fg : theme.text} attributes={active() ? TextAttributes.BOLD : undefined}>
+          {action.item.title}
+        </text>
+        <text fg={active() ? fg : theme.textMuted}> {action.item.label}</text>
+      </box>
+    )
+  }
+
   return (
     <box gap={1} paddingBottom={1} flexGrow={1}>
       <box paddingLeft={4} paddingRight={4}>
@@ -445,6 +534,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                           position="relative"
                           onMouseMove={() => {
                             setStore("input", "mouse")
+                            setFocusedAction(undefined)
                           }}
                           onMouseUp={() => {
                             option.onSelect?.(dialog)
@@ -467,7 +557,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                             paddingLeft={current() || option.gutter ? 1 : 3}
                             paddingRight={3}
                             gap={1}
-                            backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
+                            backgroundColor={
+                              active()
+                                ? actionFocused()
+                                  ? theme.backgroundElement
+                                  : (option.bg ?? theme.primary)
+                                : RGBA.fromInts(0, 0, 0, 0)
+                            }
                           >
                             <Show when={!current() && option.margin}>
                               <box position="absolute" left={1} flexShrink={0}>
@@ -483,6 +579,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                               description={option.description !== category ? option.description : undefined}
                               active={active()}
                               current={current()}
+                              muted={actionFocused()}
                               gutter={option.gutter}
                             />
                           </box>
@@ -512,31 +609,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           flexDirection="row"
           justifyContent="space-between"
           flexShrink={0}
-          paddingTop={1}
         >
           <box flexDirection="row" gap={2}>
             {props.footer}
             <For each={left()}>
-              {(item) => (
-                <text>
-                  <span style={{ fg: theme.text }}>
-                    <b>{item.title}</b>{" "}
-                  </span>
-                  <span style={{ fg: theme.textMuted }}>{item.label}</span>
-                </text>
-              )}
+              {(item) => <FooterAction item={item} />}
             </For>
           </box>
           <box flexDirection="row" gap={2}>
             <For each={right()}>
-              {(item) => (
-                <text>
-                  <span style={{ fg: theme.text }}>
-                    <b>{item.title}</b>{" "}
-                  </span>
-                  <span style={{ fg: theme.textMuted }}>{item.label}</span>
-                </text>
-              )}
+              {(item) => <FooterAction item={item} />}
             </For>
           </box>
         </box>
@@ -551,6 +633,7 @@ function Option(props: {
   description?: string
   active?: boolean
   current?: boolean
+  muted?: boolean
   footer?: JSX.Element | string
   titleWidth?: number
   truncateTitle?: boolean | "left"
@@ -559,11 +642,17 @@ function Option(props: {
 }) {
   const { theme } = useTheme()
   const fg = selectedForeground(theme)
+  const text = createMemo(() => {
+    if (props.active && !props.muted) return fg
+    if (props.muted && (props.active || props.current)) return theme.textMuted
+    if (props.current) return theme.primary
+    return theme.text
+  })
 
   return (
     <>
       <Show when={props.current}>
-        <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0}>
+        <text flexShrink={0} fg={text()} marginRight={0}>
         </text>
       </Show>
@@ -574,8 +663,8 @@ function Option(props: {
       </Show>
       <text
         flexGrow={1}
-        fg={props.active ? fg : props.current ? theme.primary : theme.text}
-        attributes={props.active ? TextAttributes.BOLD : undefined}
+        fg={text()}
+        attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
         overflow="hidden"
         wrapMode="none"
         paddingLeft={3}
@@ -587,12 +676,12 @@ function Option(props: {
               ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
               : Locale.truncate(props.title, props.titleWidth ?? 61))}
         <Show when={props.description}>
-          <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
+          <span style={{ fg: props.active && !props.muted ? fg : theme.textMuted }}> {props.description}</span>
         </Show>
       </text>
       <Show when={props.footer}>
         <box flexShrink={0}>
-          <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
+          <text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
         </box>
       </Show>
     </>