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:
parent
3151e2246a
commit
ecdfcd91ca
@ -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) {
|
||||
|
||||
@ -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"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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={[
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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) }}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user