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

This commit is contained in:
James Long 2026-06-05 13:14:13 -04:00 committed by GitHub
parent 3151e2246a
commit ecdfcd91ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 151 additions and 51 deletions

View File

@ -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) {

View File

@ -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"),
])
}),
)

View File

@ -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={[
{

View File

@ -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) {

View File

@ -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"),

View File

@ -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
})

View File

@ -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) }}

View File

@ -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>
</>