diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c0b7f4993925..aa1c3a616157 100644 --- a/packages/core/src/project.ts +++ b/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) { diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index c65ac4778ae7..645558ffb73a 100644 --- a/packages/core/test/project.test.ts +++ b/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"), ]) }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx index bb808f8a5b96..4b9fd50d1540 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx +++ b/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[]>(() => { + const options = createMemo[]>(() => { 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 {visible.slice(split)} ) : 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 { - if (option.value) props.onSelect({ type: "directory", directory: option.value }) + if (option.value) props.onSelect(option.value) }} actions={[ { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx index 780bbb92f11e..f3a037d03f7b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx +++ b/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(() => ( { const sessionID = input.sessionID() if (!sessionID) { diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 71ddea7227e1..8bc559c85334 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/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"), diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx index f2071637348c..71818dc35d1a 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx +++ b/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 }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx b/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx index 9b5bef6dbeda..0841023c1f2a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx +++ b/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() export function HomeSessionDestinationProvider(props: ParentProps) { - const [destination, setDestination] = createSignal() + const sync = useSync() + const [selected, setDestination] = createSignal() + const destination = createMemo(() => + selected() ?? { type: "directory", directory: sync.path.directory || process.cwd() }, + ) return ( setDestination(undefined) }} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index d5318a88b92f..2a1e700afe1b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/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 = { } export function DialogSelect(props: DialogSelectProps) { + type Action = NonNullable["actions"]>[number] + type FooterHint = NonNullable["footerHints"]>[number] + type VisibleAction = (Action & { label: string }) | FooterHint + const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() @@ -84,6 +88,8 @@ export function DialogSelect(props: DialogSelectProps) { filter: "", input: "keyboard" as "keyboard" | "mouse", }) + const [focusedAction, setFocusedAction] = createSignal() + const actionFocused = createMemo(() => focusedAction() !== undefined) createEffect( on( @@ -119,6 +125,18 @@ export function DialogSelect(props: DialogSelectProps) { 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(props: DialogSelectProps) { createEffect(() => { filtered() setStore("input", "keyboard") + setFocusedAction(undefined) }) const flatten = createMemo(() => props.flat && store.filter.length > 0) @@ -206,6 +225,7 @@ export function DialogSelect(props: DialogSelectProps) { } 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(props: DialogSelectProps) { 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(props: DialogSelectProps) { "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(props: DialogSelectProps) { } 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 ( + + + {action.item.title}{" "} + + {action.item.label} + + ) + const active = createMemo(() => isActionFocused(action.item)) + const fg = selectedForeground(theme) + return ( + triggerAction(action.item)} + > + + {action.item.title} + + {action.item.label} + + ) + } + return ( @@ -445,6 +534,7 @@ export function DialogSelect(props: DialogSelectProps) { position="relative" onMouseMove={() => { setStore("input", "mouse") + setFocusedAction(undefined) }} onMouseUp={() => { option.onSelect?.(dialog) @@ -467,7 +557,13 @@ export function DialogSelect(props: DialogSelectProps) { 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) + } > @@ -483,6 +579,7 @@ export function DialogSelect(props: DialogSelectProps) { description={option.description !== category ? option.description : undefined} active={active()} current={current()} + muted={actionFocused()} gutter={option.gutter} /> @@ -512,31 +609,16 @@ export function DialogSelect(props: DialogSelectProps) { flexDirection="row" justifyContent="space-between" flexShrink={0} - paddingTop={1} > {props.footer} - {(item) => ( - - - {item.title}{" "} - - {item.label} - - )} + {(item) => } - {(item) => ( - - - {item.title}{" "} - - {item.label} - - )} + {(item) => } @@ -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 ( <> - + @@ -574,8 +663,8 @@ function Option(props: { - {props.description} + {props.description} - {props.footer} + {props.footer}